forked from Github/frigate
Compare commits
54 Commits
v0.9.0
...
v0.10.0-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b912851e49 | ||
|
|
14c74e4361 | ||
|
|
51fb532e1a | ||
|
|
3541f966e3 | ||
|
|
c7faef8faa | ||
|
|
cdd3000315 | ||
|
|
1c1c28d0e5 | ||
|
|
4422e86907 | ||
|
|
8f43a2d109 | ||
|
|
bd7755fdd3 | ||
|
|
d554175631 | ||
|
|
ff667b019a | ||
|
|
57dcb29f8b | ||
|
|
9dc6c423b7 | ||
|
|
58117e2a3e | ||
|
|
5bec438f9c | ||
|
|
24cc63d6d3 | ||
|
|
d17bd74c9a | ||
|
|
8f101ccca8 | ||
|
|
b63c56d810 | ||
|
|
61c62d4685 | ||
|
|
26ae6084ea | ||
|
|
76142e9699 | ||
|
|
5e692acfbb | ||
|
|
a67b8ab84d | ||
|
|
4cf55ad8e2 | ||
|
|
c1132e6897 | ||
|
|
d6104f2eb2 | ||
|
|
b0e0abe385 | ||
|
|
4916e1cd1d | ||
|
|
cd87f3e6f4 | ||
|
|
18f4ab2644 | ||
|
|
0bd3be94ec | ||
|
|
25bb515afc | ||
|
|
7ab6961ee1 | ||
|
|
ae24cf3bb2 | ||
|
|
2e494477a6 | ||
|
|
80b72c75d9 | ||
|
|
9494bb7f5f | ||
|
|
86a741b6e6 | ||
|
|
f738275d21 | ||
|
|
e297e02800 | ||
|
|
b2e05afff2 | ||
|
|
05fc35fc3d | ||
|
|
c809494c98 | ||
|
|
ef82c5c691 | ||
|
|
c0e2a75715 | ||
|
|
01ddd00bc5 | ||
|
|
d150f01a2c | ||
|
|
f9e159deaf | ||
|
|
381b00157e | ||
|
|
800f33e7be | ||
|
|
b8218876be | ||
|
|
5669f4c161 |
@@ -7,4 +7,5 @@ config/
|
||||
.git
|
||||
core
|
||||
*.mp4
|
||||
*.db
|
||||
*.db
|
||||
*.ts
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -6,7 +6,9 @@ debug
|
||||
config/config.yml
|
||||
models
|
||||
*.mp4
|
||||
*.ts
|
||||
*.db
|
||||
*.csv
|
||||
frigate/version.py
|
||||
web/build
|
||||
web/node_modules
|
||||
|
||||
2
Makefile
2
Makefile
@@ -3,7 +3,7 @@ default_target: amd64_frigate
|
||||
COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1)
|
||||
|
||||
version:
|
||||
echo "VERSION='0.9.0-$(COMMIT_HASH)'" > frigate/version.py
|
||||
echo "VERSION='0.10.0-$(COMMIT_HASH)'" > frigate/version.py
|
||||
|
||||
web:
|
||||
docker build --tag frigate-web --file docker/Dockerfile.web web/
|
||||
|
||||
@@ -31,6 +31,7 @@ detectors:
|
||||
```
|
||||
|
||||
### Native Coral (Dev Board)
|
||||
_warning: may have [compatibility issues](https://github.com/blakeblackshear/frigate/issues/1706) after `v0.9.x`_
|
||||
|
||||
```yaml
|
||||
detectors:
|
||||
|
||||
@@ -48,8 +48,8 @@ mqtt:
|
||||
# Optional: user
|
||||
user: mqtt_user
|
||||
# Optional: password
|
||||
# NOTE: Environment variables that begin with 'FRIGATE_' may be referenced in {}.
|
||||
# eg. password: '{FRIGATE_MQTT_PASSWORD}'
|
||||
# NOTE: MQTT password can be specified with an environment variables that must begin with 'FRIGATE_'.
|
||||
# e.g. password: '{FRIGATE_MQTT_PASSWORD}'
|
||||
password: password
|
||||
# Optional: tls_ca_certs for enabling TLS using self-signed certs (default: None)
|
||||
tls_ca_certs: /path/to/ca.crt
|
||||
@@ -159,6 +159,8 @@ detect:
|
||||
enabled: True
|
||||
# Optional: Number of frames without a detection before frigate considers an object to be gone. (default: 5x the frame rate)
|
||||
max_disappeared: 25
|
||||
# Optional: Frequency for running detection on stationary objects (default: 10x the frame rate)
|
||||
stationary_interval: 50
|
||||
|
||||
# Optional: Object configuration
|
||||
# NOTE: Can be overridden at the camera level
|
||||
@@ -192,10 +194,14 @@ motion:
|
||||
# Increasing this value will make motion detection less sensitive and decreasing it will make motion detection more sensitive.
|
||||
# The value should be between 1 and 255.
|
||||
threshold: 25
|
||||
# Optional: Minimum size in pixels in the resized motion image that counts as motion (default: ~0.17% of the motion frame area)
|
||||
# Increasing this value will prevent smaller areas of motion from being detected. Decreasing will make motion detection more sensitive to smaller
|
||||
# moving objects.
|
||||
contour_area: 100
|
||||
# Optional: Minimum size in pixels in the resized motion image that counts as motion (default: 30)
|
||||
# Increasing this value will prevent smaller areas of motion from being detected. Decreasing will
|
||||
# make motion detection more sensitive to smaller moving objects.
|
||||
# As a rule of thumb:
|
||||
# - 15 - high sensitivity
|
||||
# - 30 - medium sensitivity
|
||||
# - 50 - low sensitivity
|
||||
contour_area: 30
|
||||
# Optional: Alpha value passed to cv2.accumulateWeighted when averaging the motion delta across multiple frames (default: shown below)
|
||||
# Higher values mean the current frame impacts the delta a lot, and a single raindrop may register as motion.
|
||||
# Too low and a fast moving person wont be detected as motion.
|
||||
@@ -205,10 +211,10 @@ motion:
|
||||
# Low values will cause things like moving shadows to be detected as motion for longer.
|
||||
# https://www.geeksforgeeks.org/background-subtraction-in-an-image-using-concept-of-running-average/
|
||||
frame_alpha: 0.2
|
||||
# Optional: Height of the resized motion frame (default: 1/6th of the original frame height, but no less than 180)
|
||||
# This operates as an efficient blur alternative. Higher values will result in more granular motion detection at the expense of higher CPU usage.
|
||||
# Lower values result in less CPU, but small changes may not register as motion.
|
||||
frame_height: 180
|
||||
# Optional: Height of the resized motion frame (default: 50)
|
||||
# This operates as an efficient blur alternative. Higher values will result in more granular motion detection at the expense
|
||||
# of higher CPU usage. Lower values result in less CPU, but small changes may not register as motion.
|
||||
frame_height: 50
|
||||
# Optional: motion mask
|
||||
# NOTE: see docs for more detailed info on creating masks
|
||||
mask: 0,900,1080,900,1080,1920,0,1920
|
||||
@@ -319,7 +325,7 @@ cameras:
|
||||
# Required: A list of input streams for the camera. See documentation for more information.
|
||||
inputs:
|
||||
# Required: the path to the stream
|
||||
# NOTE: Environment variables that begin with 'FRIGATE_' may be referenced in {}
|
||||
# NOTE: path may include environment variables, which must begin with 'FRIGATE_' and be referenced in {}
|
||||
- path: rtsp://viewer:{FRIGATE_RTSP_PASSWORD}@10.0.10.10:554/cam/realmonitor?channel=1&subtype=2
|
||||
# Required: list of roles for this stream. valid values are: detect,record,rtmp
|
||||
# NOTICE: In addition to assigning the record, and rtmp roles,
|
||||
|
||||
@@ -22,4 +22,4 @@ record:
|
||||
|
||||
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`, events will have up to `max_seconds` (defaults to 5 minutes) of recordings retained. Increasing `retain_days` to `1` will allow events to exceed the `max_seconds` limitation of up to 1 day.
|
||||
When `retain_days` is set to `0`, segments will be deleted from the cache if no events are in progress
|
||||
|
||||
@@ -13,7 +13,7 @@ A solid green image means that frigate has not received any frames from ffmpeg.
|
||||
|
||||
### 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#ffmpeg).
|
||||
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).
|
||||
|
||||
### My mjpeg stream or snapshots look green and crazy
|
||||
|
||||
|
||||
@@ -44,3 +44,25 @@ The USB version is compatible with the widest variety of hardware and does not r
|
||||
The PCIe and M.2 versions require installation of a driver on the host. Follow the instructions for your version from https://coral.ai
|
||||
|
||||
A single Coral can handle many cameras and will be sufficient for the majority of users. You can calculate the maximum performance of your Coral based on the inference speed reported by Frigate. With an inference speed of 10, your Coral will top out at `1000/10=100`, or 100 frames per second. If your detection fps is regularly getting close to that, you should first consider tuning motion masks. If those are already properly configured, a second Coral may be needed.
|
||||
|
||||
### What does Frigate use the CPU for and what does it use the Coral for? (ELI5 Version)
|
||||
|
||||
This is taken from a [user question on reddit](https://www.reddit.com/r/homeassistant/comments/q8mgau/comment/hgqbxh5/?utm_source=share&utm_medium=web2x&context=3). Modified slightly for clarity.
|
||||
|
||||
CPU Usage: I am a CPU, Mendel is a Google Coral
|
||||
|
||||
My buddy Mendel and I have been tasked with keeping the neighbor's red footed booby off my parent's yard. Now I'm really bad at identifying birds. It takes me forever, but my buddy Mendel is incredible at it.
|
||||
|
||||
Mendel however, struggles at pretty much anything else. So we make an agreement. I wait till I see something that moves, and snap a picture of it for Mendel. I then show him the picture and he tells me what it is. Most of the time it isn't anything. But eventually I see some movement and Mendel tells me it is the Booby. Score!
|
||||
|
||||
_What happens when I increase the resolution of my camera?_
|
||||
|
||||
However we realize that there is a problem. There is still booby poop all over the yard. How could we miss that! I've been watching all day! My parents check the window and realize its dirty and a bit small to see the entire yard so they clean it and put a bigger one in there. Now there is so much more to see! However I now have a much bigger area to scan for movement and have to work a lot harder! Even my buddy Mendel has to work harder, as now the pictures have a lot more detail in them that he has to look at to see if it is our sneaky booby.
|
||||
|
||||
Basically - When you increase the resolution and/or the frame rate of the stream there is now significantly more data for the CPU to parse. That takes additional computing power. The Google Coral is really good at doing object detection, but it doesn't have time to look everywhere all the time (especially when there are many windows to check). To balance it, Frigate uses the CPU to look for movement, then sends those frames to the Coral to do object detection. This allows the Coral to be available to a large number of cameras and not overload it.
|
||||
|
||||
### Do hwaccel args help if I am using a Coral?
|
||||
|
||||
YES! The Coral does not help with decoding video streams.
|
||||
|
||||
Decompressing video streams takes a significant amount of CPU power. Video compression uses key frames (also known as I-frames) to send a full frame in the video stream. The following frames only include the difference from the key frame, and the CPU has to compile each frame by merging the differences with the key frame. [More detailed explanation](https://blog.video.ibm.com/streaming-video-tips/keyframes-interframe-video-compression/). Higher resolutions and frame rates mean more processing power is needed to decode the video stream, so try and set them on the camera to avoid unnecessary decoding work.
|
||||
|
||||
@@ -17,13 +17,68 @@ Frigate runs best with docker installed on bare metal debian-based distributions
|
||||
|
||||
Windows is not officially supported, but some users have had success getting it to run under WSL or Virtualbox. Getting the GPU and/or Coral devices properly passed to Frigate may be difficult or impossible. Search previous discussions or issues for help.
|
||||
|
||||
### Storage
|
||||
|
||||
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.
|
||||
|
||||
- `/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/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.
|
||||
- `/tmp/cache`: Cache location for recording segments. Initial recordings are written here before being checked and converted to mp4 and moved to the recordings folder.
|
||||
- `/dev/shm`: It is not recommended to modify this directory or map it with docker. This is the location for raw decoded frames in shared memory and it's size is impacted by the `shm-size` calculations below.
|
||||
- `/config/config.yml`: Default location of the config file.
|
||||
|
||||
#### Common docker compose storage configurations
|
||||
|
||||
Writing to a local disk or external USB drive:
|
||||
|
||||
```yaml
|
||||
version: "3.9"
|
||||
services:
|
||||
frigate:
|
||||
...
|
||||
volumes:
|
||||
- /path/to/your/config.yml:/config/config.yml:ro
|
||||
- /path/to/your/storage:/media/frigate
|
||||
- type: tmpfs # Optional: 1GB of memory, reduces SSD/SD Card wear
|
||||
target: /tmp/cache
|
||||
tmpfs:
|
||||
size: 1000000000
|
||||
...
|
||||
```
|
||||
|
||||
Writing to a network drive with database on a local drive:
|
||||
|
||||
```yaml
|
||||
version: "3.9"
|
||||
services:
|
||||
frigate:
|
||||
...
|
||||
volumes:
|
||||
- /path/to/your/config.yml:/config/config.yml:ro
|
||||
- /path/to/network/storage:/media/frigate
|
||||
- /path/to/local/disk:/db
|
||||
- type: tmpfs # Optional: 1GB of memory, reduces SSD/SD Card wear
|
||||
target: /tmp/cache
|
||||
tmpfs:
|
||||
size: 1000000000
|
||||
...
|
||||
```
|
||||
|
||||
frigate.yml
|
||||
|
||||
```yaml
|
||||
database:
|
||||
path: /db/frigate.db
|
||||
```
|
||||
|
||||
### Calculating required shm-size
|
||||
|
||||
Frigate utilizes shared memory to store frames during processing. The default `shm-size` provided by Docker is 64m.
|
||||
|
||||
The default shm-size of 64m is fine for setups with 2 or less 1080p cameras. If frigate is exiting with "Bus error" messages, it is likely because you have too many high resolution cameras and you need to specify a higher shm size.
|
||||
|
||||
You can calculate the necessary shm-size for each camera with the following formula:
|
||||
You can calculate the necessary shm-size for each camera with the following formula using the resolution specified for detect:
|
||||
|
||||
```
|
||||
(width * height * 1.5 * 9 + 270480)/1048576 = <shm size in mb>
|
||||
@@ -102,7 +157,7 @@ docker run -d \
|
||||
|
||||
:::caution
|
||||
|
||||
Due to limitations in Home Assistant Operating System, Frigate cannot utilize external storage for recordings or snapshots.
|
||||
Due to limitations in Home Assistant Operating System, utilizing external storage for recordings or snapshots requires [modifying udev rules manually](https://community.home-assistant.io/t/solved-mount-usb-drive-in-hassio-to-be-used-on-the-media-folder-with-udev-customization/258406/46).
|
||||
|
||||
:::
|
||||
|
||||
|
||||
@@ -29,6 +29,16 @@ module.exports = {
|
||||
label: 'Docs',
|
||||
position: 'left',
|
||||
},
|
||||
{
|
||||
href: 'https://frigate.video',
|
||||
label: 'Website',
|
||||
position: 'right',
|
||||
},
|
||||
{
|
||||
href: 'https://demo.frigate.video',
|
||||
label: 'Demo',
|
||||
position: 'right',
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/blakeblackshear/frigate',
|
||||
label: 'GitHub',
|
||||
|
||||
54
docs/package-lock.json
generated
54
docs/package-lock.json
generated
@@ -2469,31 +2469,11 @@
|
||||
"integrity": "sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM="
|
||||
},
|
||||
"ansi-align": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.0.tgz",
|
||||
"integrity": "sha512-ZpClVKqXN3RGBmKibdfWzqCY4lnjEuoNzU5T0oEFpfd/z5qJHVarukridD4juLO2FXMiwUQxr9WqQtaYa8XRYw==",
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz",
|
||||
"integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==",
|
||||
"requires": {
|
||||
"string-width": "^3.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"string-width": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz",
|
||||
"integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==",
|
||||
"requires": {
|
||||
"emoji-regex": "^7.0.1",
|
||||
"is-fullwidth-code-point": "^2.0.0",
|
||||
"strip-ansi": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"strip-ansi": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz",
|
||||
"integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==",
|
||||
"requires": {
|
||||
"ansi-regex": "^4.1.0"
|
||||
}
|
||||
}
|
||||
"string-width": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"ansi-colors": {
|
||||
@@ -2902,15 +2882,15 @@
|
||||
"integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24="
|
||||
},
|
||||
"boxen": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/boxen/-/boxen-5.0.1.tgz",
|
||||
"integrity": "sha512-49VBlw+PrWEF51aCmy7QIteYPIFZxSpvqBdP/2itCPPlJ49kj9zg/XPRFrdkne2W+CfwXUls8exMvu1RysZpKA==",
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/boxen/-/boxen-5.1.2.tgz",
|
||||
"integrity": "sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==",
|
||||
"requires": {
|
||||
"ansi-align": "^3.0.0",
|
||||
"camelcase": "^6.2.0",
|
||||
"chalk": "^4.1.0",
|
||||
"cli-boxes": "^2.2.1",
|
||||
"string-width": "^4.2.0",
|
||||
"string-width": "^4.2.2",
|
||||
"type-fest": "^0.20.2",
|
||||
"widest-line": "^3.1.0",
|
||||
"wrap-ansi": "^7.0.0"
|
||||
@@ -6826,9 +6806,9 @@
|
||||
"integrity": "sha1-y480xTIT2JVyP8urkH6UIq28r7E="
|
||||
},
|
||||
"nth-check": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.0.0.tgz",
|
||||
"integrity": "sha512-i4sc/Kj8htBrAiH1viZ0TgU8Y5XqCaV/FziYK6TBczxmeKm3AEFWqqF3195yKudrarqy7Zu80Ra5dobFjn9X/Q==",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.0.1.tgz",
|
||||
"integrity": "sha512-it1vE95zF6dTT9lBsYbxvqh0Soy4SPowchj0UBGj/V6cTPnXXtQOPUbhZ6CmGzAD/rW22LQK6E96pcdJXk4A4w==",
|
||||
"requires": {
|
||||
"boolbase": "^1.0.0"
|
||||
}
|
||||
@@ -7650,9 +7630,9 @@
|
||||
"integrity": "sha512-w23ch4f75V1Tnz8DajsYKvY5lF7H1+WvzvLUcF0paFxkTHSp42RS0H5CttdN2Q8RR3DRGZ9v5xD/h3n8C8kGmg=="
|
||||
},
|
||||
"prismjs": {
|
||||
"version": "1.24.1",
|
||||
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.24.1.tgz",
|
||||
"integrity": "sha512-mNPsedLuk90RVJioIky8ANZEwYm5w9LcvCXrxHlwf4fNVSn8jEipMybMkWUyyF0JhnC+C4VcOVSBuHRKs1L5Ow=="
|
||||
"version": "1.25.0",
|
||||
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.25.0.tgz",
|
||||
"integrity": "sha512-WCjJHl1KEWbnkQom1+SzftbtXMKQoezOCYs5rECqMN+jP+apI7ftoflyqigqzopSO3hMhTEb0mFClA8lkolgEg=="
|
||||
},
|
||||
"process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
@@ -9397,9 +9377,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"ansi-regex": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
|
||||
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg=="
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
"clear": "docusaurus clear"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "^2.0.0-beta.ff31de0ff",
|
||||
"@docusaurus/preset-classic": "^2.0.0-beta.ff31de0ff",
|
||||
"@docusaurus/core": "^2.0.0-beta.6",
|
||||
"@docusaurus/preset-classic": "^2.0.0-beta.6",
|
||||
"@mdx-js/react": "^1.6.21",
|
||||
"clsx": "^1.1.1",
|
||||
"raw-loader": "^4.0.2",
|
||||
|
||||
@@ -12,6 +12,7 @@ import yaml
|
||||
from peewee_migrate import Router
|
||||
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||
from playhouse.sqliteq import SqliteQueueDatabase
|
||||
from pydantic import ValidationError
|
||||
|
||||
from frigate.config import DetectorTypeEnum, FrigateConfig
|
||||
from frigate.const import CACHE_DIR, CLIPS_DIR, RECORD_DIR
|
||||
@@ -70,6 +71,9 @@ class FrigateApp:
|
||||
self.config = user_config.runtime_config
|
||||
|
||||
for camera_name in self.config.cameras.keys():
|
||||
# generage the ffmpeg commands
|
||||
self.config.cameras[camera_name].create_ffmpeg_cmds()
|
||||
|
||||
# create camera_metrics
|
||||
self.camera_metrics[camera_name] = {
|
||||
"camera_fps": mp.Value("d", 0.0),
|
||||
@@ -85,29 +89,6 @@ class FrigateApp:
|
||||
"frame_queue": mp.Queue(maxsize=2),
|
||||
}
|
||||
|
||||
def check_config(self):
|
||||
for name, camera in self.config.cameras.items():
|
||||
assigned_roles = list(
|
||||
set([r for i in camera.ffmpeg.inputs for r in i.roles])
|
||||
)
|
||||
if not camera.record.enabled and "record" in assigned_roles:
|
||||
logger.warning(
|
||||
f"Camera {name} has record assigned to an input, but record is not enabled."
|
||||
)
|
||||
elif camera.record.enabled and not "record" in assigned_roles:
|
||||
logger.warning(
|
||||
f"Camera {name} has record enabled, but record is not assigned to an input."
|
||||
)
|
||||
|
||||
if not camera.rtmp.enabled and "rtmp" in assigned_roles:
|
||||
logger.warning(
|
||||
f"Camera {name} has rtmp assigned to an input, but rtmp is not enabled."
|
||||
)
|
||||
elif camera.rtmp.enabled and not "rtmp" in assigned_roles:
|
||||
logger.warning(
|
||||
f"Camera {name} has rtmp enabled, but rtmp is not assigned to an input."
|
||||
)
|
||||
|
||||
def set_log_levels(self):
|
||||
logging.getLogger().setLevel(self.config.logger.default.value.upper())
|
||||
for log, level in self.config.logger.logs.items():
|
||||
@@ -320,12 +301,23 @@ class FrigateApp:
|
||||
try:
|
||||
self.init_config()
|
||||
except Exception as e:
|
||||
print(f"Error parsing config: {e}")
|
||||
print("*************************************************************")
|
||||
print("*************************************************************")
|
||||
print("*** Your config file is not valid! ***")
|
||||
print("*** Please check the docs at ***")
|
||||
print("*** https://docs.frigate.video/configuration/index ***")
|
||||
print("*************************************************************")
|
||||
print("*************************************************************")
|
||||
print("*** Config Validation Errors ***")
|
||||
print("*************************************************************")
|
||||
print(e)
|
||||
print("*************************************************************")
|
||||
print("*** End Config Validation Errors ***")
|
||||
print("*************************************************************")
|
||||
self.log_process.terminate()
|
||||
sys.exit(1)
|
||||
self.set_environment_vars()
|
||||
self.ensure_dirs()
|
||||
self.check_config()
|
||||
self.set_log_levels()
|
||||
self.init_queues()
|
||||
self.init_database()
|
||||
|
||||
BIN
frigate/birdseye.png
Normal file
BIN
frigate/birdseye.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
@@ -12,7 +12,7 @@ import yaml
|
||||
from pydantic import BaseModel, Extra, Field, validator
|
||||
from pydantic.fields import PrivateAttr
|
||||
|
||||
from frigate.const import BASE_DIR, CACHE_DIR, RECORD_DIR
|
||||
from frigate.const import BASE_DIR, CACHE_DIR
|
||||
from frigate.edgetpu import load_labels
|
||||
from frigate.util import create_mask, deep_merge
|
||||
|
||||
@@ -103,10 +103,10 @@ class MotionConfig(FrigateBaseModel):
|
||||
ge=1,
|
||||
le=255,
|
||||
)
|
||||
contour_area: Optional[int] = Field(title="Contour Area")
|
||||
contour_area: Optional[int] = Field(default=30, title="Contour Area")
|
||||
delta_alpha: float = Field(default=0.2, title="Delta Alpha")
|
||||
frame_alpha: float = Field(default=0.2, title="Frame Alpha")
|
||||
frame_height: Optional[int] = Field(title="Frame Height")
|
||||
frame_height: Optional[int] = Field(default=50, title="Frame Height")
|
||||
mask: Union[str, List[str]] = Field(
|
||||
default="", title="Coordinates polygon for the motion mask."
|
||||
)
|
||||
@@ -119,15 +119,6 @@ class RuntimeMotionConfig(MotionConfig):
|
||||
def __init__(self, **config):
|
||||
frame_shape = config.get("frame_shape", (1, 1))
|
||||
|
||||
if "frame_height" not in config:
|
||||
config["frame_height"] = max(frame_shape[0] // 6, 180)
|
||||
|
||||
if "contour_area" not in config:
|
||||
frame_width = frame_shape[1] * config["frame_height"] / frame_shape[0]
|
||||
config["contour_area"] = (
|
||||
config["frame_height"] * frame_width * 0.00173611111
|
||||
)
|
||||
|
||||
mask = config.get("mask", "")
|
||||
config["raw_mask"] = mask
|
||||
|
||||
@@ -162,6 +153,9 @@ class DetectConfig(FrigateBaseModel):
|
||||
max_disappeared: Optional[int] = Field(
|
||||
title="Maximum number of frames the object can dissapear before detection ends."
|
||||
)
|
||||
stationary_interval: Optional[int] = Field(
|
||||
title="Frame interval for checking stationary objects."
|
||||
)
|
||||
|
||||
|
||||
class FilterConfig(FrigateBaseModel):
|
||||
@@ -495,6 +489,7 @@ class CameraConfig(FrigateBaseModel):
|
||||
timestamp_style: TimestampStyleConfig = Field(
|
||||
default_factory=TimestampStyleConfig, title="Timestamp style configuration."
|
||||
)
|
||||
_ffmpeg_cmds: List[Dict[str, List[str]]] = PrivateAttr()
|
||||
|
||||
def __init__(self, **config):
|
||||
# Set zone colors
|
||||
@@ -505,6 +500,10 @@ class CameraConfig(FrigateBaseModel):
|
||||
for idx, (name, z) in enumerate(config["zones"].items())
|
||||
}
|
||||
|
||||
# add roles to the input if there is only one
|
||||
if len(config["ffmpeg"]["inputs"]) == 1:
|
||||
config["ffmpeg"]["inputs"][0]["roles"] = ["record", "rtmp", "detect"]
|
||||
|
||||
super().__init__(**config)
|
||||
|
||||
@property
|
||||
@@ -517,6 +516,9 @@ class CameraConfig(FrigateBaseModel):
|
||||
|
||||
@property
|
||||
def ffmpeg_cmds(self) -> List[Dict[str, List[str]]]:
|
||||
return self._ffmpeg_cmds
|
||||
|
||||
def create_ffmpeg_cmds(self):
|
||||
ffmpeg_cmds = []
|
||||
for ffmpeg_input in self.ffmpeg.inputs:
|
||||
ffmpeg_cmd = self._get_ffmpeg_cmd(ffmpeg_input)
|
||||
@@ -524,7 +526,7 @@ class CameraConfig(FrigateBaseModel):
|
||||
continue
|
||||
|
||||
ffmpeg_cmds.append({"roles": ffmpeg_input.roles, "cmd": ffmpeg_cmd})
|
||||
return ffmpeg_cmds
|
||||
self._ffmpeg_cmds = ffmpeg_cmds
|
||||
|
||||
def _get_ffmpeg_cmd(self, ffmpeg_input: CameraInput):
|
||||
ffmpeg_output_args = []
|
||||
@@ -560,6 +562,7 @@ class CameraConfig(FrigateBaseModel):
|
||||
if isinstance(self.ffmpeg.output_args.record, list)
|
||||
else self.ffmpeg.output_args.record.split(" ")
|
||||
)
|
||||
|
||||
ffmpeg_output_args = (
|
||||
record_args
|
||||
+ [f"{os.path.join(CACHE_DIR, self.name)}-%Y%m%d%H%M%S.mp4"]
|
||||
@@ -740,6 +743,11 @@ class FrigateConfig(FrigateBaseModel):
|
||||
if camera_config.detect.max_disappeared is None:
|
||||
camera_config.detect.max_disappeared = max_disappeared
|
||||
|
||||
# Default stationary_interval configuration
|
||||
stationary_interval = camera_config.detect.fps * 10
|
||||
if camera_config.detect.stationary_interval is None:
|
||||
camera_config.detect.stationary_interval = stationary_interval
|
||||
|
||||
# FFMPEG input substitution
|
||||
for input in camera_config.ffmpeg.inputs:
|
||||
input.path = input.path.format(**FRIGATE_ENV_VARS)
|
||||
@@ -787,6 +795,20 @@ class FrigateConfig(FrigateBaseModel):
|
||||
**camera_config.motion.dict(exclude_unset=True),
|
||||
)
|
||||
|
||||
# check runtime config
|
||||
assigned_roles = list(
|
||||
set([r for i in camera_config.ffmpeg.inputs for r in i.roles])
|
||||
)
|
||||
if camera_config.record.enabled and not "record" in assigned_roles:
|
||||
raise ValueError(
|
||||
f"Camera {name} has record enabled, but record is not assigned to an input."
|
||||
)
|
||||
|
||||
if camera_config.rtmp.enabled and not "rtmp" in assigned_roles:
|
||||
raise ValueError(
|
||||
f"Camera {name} has rtmp enabled, but rtmp is not assigned to an input."
|
||||
)
|
||||
|
||||
config.cameras[name] = camera_config
|
||||
|
||||
return config
|
||||
|
||||
@@ -30,6 +30,11 @@ class EventProcessor(threading.Thread):
|
||||
self.stop_event = stop_event
|
||||
|
||||
def run(self):
|
||||
# set an end_time on events without an end_time on startup
|
||||
Event.update(end_time=Event.start_time + 30).where(
|
||||
Event.end_time == None
|
||||
).execute()
|
||||
|
||||
while not self.stop_event.is_set():
|
||||
try:
|
||||
event_type, camera, event_data = self.event_queue.get(timeout=10)
|
||||
@@ -38,14 +43,35 @@ class EventProcessor(threading.Thread):
|
||||
|
||||
logger.debug(f"Event received: {event_type} {camera} {event_data['id']}")
|
||||
|
||||
event_config: EventsConfig = self.config.cameras[camera].record.events
|
||||
|
||||
if event_type == "start":
|
||||
self.events_in_process[event_data["id"]] = event_data
|
||||
|
||||
if event_type == "end":
|
||||
event_config: EventsConfig = self.config.cameras[camera].record.events
|
||||
|
||||
elif event_type == "update":
|
||||
self.events_in_process[event_data["id"]] = event_data
|
||||
# TODO: this will generate a lot of db activity possibly
|
||||
if event_data["has_clip"] or event_data["has_snapshot"]:
|
||||
Event.create(
|
||||
Event.replace(
|
||||
id=event_data["id"],
|
||||
label=event_data["label"],
|
||||
camera=camera,
|
||||
start_time=event_data["start_time"] - event_config.pre_capture,
|
||||
end_time=None,
|
||||
top_score=event_data["top_score"],
|
||||
false_positive=event_data["false_positive"],
|
||||
zones=list(event_data["entered_zones"]),
|
||||
thumbnail=event_data["thumbnail"],
|
||||
region=event_data["region"],
|
||||
box=event_data["box"],
|
||||
area=event_data["area"],
|
||||
has_clip=event_data["has_clip"],
|
||||
has_snapshot=event_data["has_snapshot"],
|
||||
).execute()
|
||||
|
||||
elif event_type == "end":
|
||||
if event_data["has_clip"] or event_data["has_snapshot"]:
|
||||
Event.replace(
|
||||
id=event_data["id"],
|
||||
label=event_data["label"],
|
||||
camera=camera,
|
||||
@@ -60,11 +86,15 @@ class EventProcessor(threading.Thread):
|
||||
area=event_data["area"],
|
||||
has_clip=event_data["has_clip"],
|
||||
has_snapshot=event_data["has_snapshot"],
|
||||
)
|
||||
).execute()
|
||||
|
||||
del self.events_in_process[event_data["id"]]
|
||||
self.event_processed_queue.put((event_data["id"], camera))
|
||||
|
||||
# set an end_time on events without an end_time before exiting
|
||||
Event.update(end_time=datetime.datetime.now().timestamp()).where(
|
||||
Event.end_time == None
|
||||
).execute()
|
||||
logger.info(f"Exiting event processor...")
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import base64
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime, timedelta
|
||||
import copy
|
||||
import json
|
||||
import glob
|
||||
import logging
|
||||
@@ -190,7 +191,7 @@ def event_snapshot(id):
|
||||
download = request.args.get("download", type=bool)
|
||||
jpg_bytes = None
|
||||
try:
|
||||
event = Event.get(Event.id == id)
|
||||
event = Event.get(Event.id == id, Event.end_time != None)
|
||||
if not event.has_snapshot:
|
||||
return "Snapshot not available", 404
|
||||
# read snapshot from disk
|
||||
@@ -321,7 +322,7 @@ def config():
|
||||
# add in the ffmpeg_cmds
|
||||
for camera_name, camera in current_app.frigate_config.cameras.items():
|
||||
camera_dict = config["cameras"][camera_name]
|
||||
camera_dict["ffmpeg_cmds"] = camera.ffmpeg_cmds
|
||||
camera_dict["ffmpeg_cmds"] = copy.deepcopy(camera.ffmpeg_cmds)
|
||||
for cmd in camera_dict["ffmpeg_cmds"]:
|
||||
cmd["cmd"] = " ".join(cmd["cmd"])
|
||||
|
||||
@@ -697,7 +698,10 @@ def vod_event(id):
|
||||
clip_path = os.path.join(CLIPS_DIR, f"{event.camera}-{id}.mp4")
|
||||
|
||||
if not os.path.isfile(clip_path):
|
||||
return vod_ts(event.camera, event.start_time, event.end_time)
|
||||
end_ts = (
|
||||
datetime.now().timestamp() if event.end_time is None else event.end_time
|
||||
)
|
||||
return vod_ts(event.camera, event.start_time, end_ts)
|
||||
|
||||
duration = int((event.end_time - event.start_time) * 1000)
|
||||
return jsonify(
|
||||
|
||||
@@ -23,6 +23,7 @@ class MotionDetector:
|
||||
interpolation=cv2.INTER_LINEAR,
|
||||
)
|
||||
self.mask = np.where(resized_mask == [0])
|
||||
self.save_images = False
|
||||
|
||||
def detect(self, frame):
|
||||
motion_boxes = []
|
||||
@@ -36,10 +37,13 @@ class MotionDetector:
|
||||
interpolation=cv2.INTER_LINEAR,
|
||||
)
|
||||
|
||||
# TODO: can I improve the contrast of the grayscale image here?
|
||||
|
||||
# convert to grayscale
|
||||
# resized_frame = cv2.cvtColor(resized_frame, cv2.COLOR_BGR2GRAY)
|
||||
# Improve contrast
|
||||
minval = np.percentile(resized_frame, 4)
|
||||
maxval = np.percentile(resized_frame, 96)
|
||||
resized_frame = np.clip(resized_frame, minval, maxval)
|
||||
resized_frame = (((resized_frame - minval) / (maxval - minval)) * 255).astype(
|
||||
np.uint8
|
||||
)
|
||||
|
||||
# mask frame
|
||||
resized_frame[self.mask] = [255]
|
||||
@@ -49,6 +53,8 @@ class MotionDetector:
|
||||
if self.frame_counter < 30:
|
||||
self.frame_counter += 1
|
||||
else:
|
||||
if self.save_images:
|
||||
self.frame_counter += 1
|
||||
# compare to average
|
||||
frameDelta = cv2.absdiff(resized_frame, cv2.convertScaleAbs(self.avg_frame))
|
||||
|
||||
@@ -58,7 +64,6 @@ class MotionDetector:
|
||||
cv2.accumulateWeighted(frameDelta, self.avg_delta, self.config.delta_alpha)
|
||||
|
||||
# compute the threshold image for the current frame
|
||||
# TODO: threshold
|
||||
current_thresh = cv2.threshold(
|
||||
frameDelta, self.config.threshold, 255, cv2.THRESH_BINARY
|
||||
)[1]
|
||||
@@ -75,8 +80,10 @@ class MotionDetector:
|
||||
|
||||
# dilate the thresholded image to fill in holes, then find contours
|
||||
# on thresholded image
|
||||
thresh = cv2.dilate(thresh, None, iterations=2)
|
||||
cnts = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
thresh_dilated = cv2.dilate(thresh, None, iterations=2)
|
||||
cnts = cv2.findContours(
|
||||
thresh_dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
|
||||
)
|
||||
cnts = imutils.grab_contours(cnts)
|
||||
|
||||
# loop over the contours
|
||||
@@ -94,6 +101,35 @@ class MotionDetector:
|
||||
)
|
||||
)
|
||||
|
||||
if self.save_images:
|
||||
thresh_dilated = cv2.cvtColor(thresh_dilated, cv2.COLOR_GRAY2BGR)
|
||||
# print("--------")
|
||||
# print(self.frame_counter)
|
||||
for c in cnts:
|
||||
contour_area = cv2.contourArea(c)
|
||||
# print(contour_area)
|
||||
if contour_area > self.config.contour_area:
|
||||
x, y, w, h = cv2.boundingRect(c)
|
||||
cv2.rectangle(
|
||||
thresh_dilated,
|
||||
(x, y),
|
||||
(x + w, y + h),
|
||||
(0, 0, 255),
|
||||
2,
|
||||
)
|
||||
# print("--------")
|
||||
image_row_1 = cv2.hconcat(
|
||||
[
|
||||
cv2.cvtColor(frameDelta, cv2.COLOR_GRAY2BGR),
|
||||
cv2.cvtColor(avg_delta_image, cv2.COLOR_GRAY2BGR),
|
||||
]
|
||||
)
|
||||
image_row_2 = cv2.hconcat(
|
||||
[cv2.cvtColor(thresh, cv2.COLOR_GRAY2BGR), thresh_dilated]
|
||||
)
|
||||
combined_image = cv2.vconcat([image_row_1, image_row_2])
|
||||
cv2.imwrite(f"motion/motion-{self.frame_counter}.jpg", combined_image)
|
||||
|
||||
if len(motion_boxes) > 0:
|
||||
self.motion_frame_count += 1
|
||||
if self.motion_frame_count >= 10:
|
||||
|
||||
@@ -603,6 +603,8 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
self.event_queue.put(("start", camera, obj.to_dict()))
|
||||
|
||||
def update(camera, obj: TrackedObject, current_frame_time):
|
||||
obj.has_snapshot = self.should_save_snapshot(camera, obj)
|
||||
obj.has_clip = self.should_retain_recording(camera, obj)
|
||||
after = obj.to_dict()
|
||||
message = {
|
||||
"before": obj.previous,
|
||||
@@ -613,6 +615,9 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
f"{self.topic_prefix}/events", json.dumps(message), retain=False
|
||||
)
|
||||
obj.previous = after
|
||||
self.event_queue.put(
|
||||
("update", camera, obj.to_dict(include_thumbnail=True))
|
||||
)
|
||||
|
||||
def end(camera, obj: TrackedObject, current_frame_time):
|
||||
# populate has_snapshot
|
||||
|
||||
@@ -13,7 +13,7 @@ import numpy as np
|
||||
from scipy.spatial import distance as dist
|
||||
|
||||
from frigate.config import DetectConfig
|
||||
from frigate.util import draw_box_with_label
|
||||
from frigate.util import intersection_over_union
|
||||
|
||||
|
||||
class ObjectTracker:
|
||||
@@ -27,6 +27,7 @@ class ObjectTracker:
|
||||
id = f"{obj['frame_time']}-{rand_id}"
|
||||
obj["id"] = id
|
||||
obj["start_time"] = obj["frame_time"]
|
||||
obj["motionless_count"] = 0
|
||||
self.tracked_objects[id] = obj
|
||||
self.disappeared[id] = 0
|
||||
|
||||
@@ -36,6 +37,13 @@ class ObjectTracker:
|
||||
|
||||
def update(self, id, new_obj):
|
||||
self.disappeared[id] = 0
|
||||
if (
|
||||
intersection_over_union(self.tracked_objects[id]["box"], new_obj["box"])
|
||||
> 0.9
|
||||
):
|
||||
self.tracked_objects[id]["motionless_count"] += 1
|
||||
else:
|
||||
self.tracked_objects[id]["motionless_count"] = 0
|
||||
self.tracked_objects[id].update(new_obj)
|
||||
|
||||
def match_and_update(self, frame_time, new_objects):
|
||||
|
||||
@@ -104,7 +104,7 @@ class BirdsEyeFrameManager:
|
||||
self.blank_frame[0 : self.frame_shape[0], 0 : self.frame_shape[1]] = 16
|
||||
|
||||
# find and copy the logo on the blank frame
|
||||
logo_files = glob.glob("/opt/frigate/web/apple-touch-icon.*.png")
|
||||
logo_files = glob.glob("/opt/frigate/frigate/birdseye.png")
|
||||
frigate_logo = None
|
||||
if len(logo_files) > 0:
|
||||
frigate_logo = cv2.imread(logo_files[0], cv2.IMREAD_UNCHANGED)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import datetime
|
||||
import time
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
@@ -7,6 +8,7 @@ import shutil
|
||||
import string
|
||||
import subprocess as sp
|
||||
import threading
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
import psutil
|
||||
@@ -45,7 +47,7 @@ class RecordingMaintainer(threading.Thread):
|
||||
self.stop_event = stop_event
|
||||
|
||||
def move_files(self):
|
||||
recordings = [
|
||||
cache_files = [
|
||||
d
|
||||
for d in os.listdir(CACHE_DIR)
|
||||
if os.path.isfile(os.path.join(CACHE_DIR, d))
|
||||
@@ -66,7 +68,9 @@ class RecordingMaintainer(threading.Thread):
|
||||
except:
|
||||
continue
|
||||
|
||||
for f in recordings:
|
||||
# group recordings by camera
|
||||
grouped_recordings = defaultdict(list)
|
||||
for f in cache_files:
|
||||
# Skip files currently in use
|
||||
if f in files_in_use:
|
||||
continue
|
||||
@@ -76,45 +80,126 @@ class RecordingMaintainer(threading.Thread):
|
||||
camera, date = basename.rsplit("-", maxsplit=1)
|
||||
start_time = datetime.datetime.strptime(date, "%Y%m%d%H%M%S")
|
||||
|
||||
# Just delete files if recordings are turned off
|
||||
if (
|
||||
not camera in self.config.cameras
|
||||
or not self.config.cameras[camera].record.enabled
|
||||
):
|
||||
Path(cache_path).unlink(missing_ok=True)
|
||||
continue
|
||||
|
||||
ffprobe_cmd = [
|
||||
"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:
|
||||
logger.warning(f"Discarding a corrupt recording segment: {f}")
|
||||
Path(cache_path).unlink(missing_ok=True)
|
||||
continue
|
||||
|
||||
directory = os.path.join(
|
||||
RECORD_DIR, start_time.strftime("%Y-%m/%d/%H"), camera
|
||||
grouped_recordings[camera].append(
|
||||
{
|
||||
"cache_path": cache_path,
|
||||
"start_time": start_time,
|
||||
}
|
||||
)
|
||||
|
||||
if not os.path.exists(directory):
|
||||
os.makedirs(directory)
|
||||
# delete all cached files past the most recent 2
|
||||
for camera in grouped_recordings.keys():
|
||||
if len(grouped_recordings[camera]) > 2:
|
||||
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:
|
||||
Path(f["cache_path"]).unlink(missing_ok=True)
|
||||
grouped_recordings[camera] = sorted_recordings[-2:]
|
||||
|
||||
file_name = f"{start_time.strftime('%M.%S.mp4')}"
|
||||
file_path = os.path.join(directory, file_name)
|
||||
for camera, recordings in grouped_recordings.items():
|
||||
# get all events with the end time after the start of the oldest cache file
|
||||
# or with end_time None
|
||||
events: Event = (
|
||||
Event.select()
|
||||
.where(
|
||||
Event.camera == camera,
|
||||
(Event.end_time == None)
|
||||
| (Event.end_time >= recordings[0]["start_time"]),
|
||||
Event.has_clip,
|
||||
)
|
||||
.order_by(Event.start_time)
|
||||
)
|
||||
for r in recordings:
|
||||
cache_path = r["cache_path"]
|
||||
start_time = r["start_time"]
|
||||
|
||||
# Just delete files if recordings are turned off
|
||||
if (
|
||||
not camera in self.config.cameras
|
||||
or not self.config.cameras[camera].record.enabled
|
||||
):
|
||||
Path(cache_path).unlink(missing_ok=True)
|
||||
continue
|
||||
|
||||
ffprobe_cmd = [
|
||||
"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:
|
||||
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 start_time <= (
|
||||
(
|
||||
datetime.datetime.now()
|
||||
- datetime.timedelta(
|
||||
days=self.config.cameras[camera].record.retain_days
|
||||
)
|
||||
)
|
||||
):
|
||||
# if the cached segment overlaps with the events:
|
||||
overlaps = False
|
||||
for event in events:
|
||||
# if the event starts in the future, stop checking events
|
||||
# and let this recording segment expire
|
||||
if event.start_time > end_time.timestamp():
|
||||
overlaps = False
|
||||
break
|
||||
|
||||
# if the event is in progress or ends after the recording starts, keep it
|
||||
# and stop looking at events
|
||||
if event.end_time is None or event.end_time >= start_time:
|
||||
overlaps = True
|
||||
break
|
||||
|
||||
if overlaps:
|
||||
# move from cache to recordings immediately
|
||||
self.store_segment(
|
||||
camera,
|
||||
start_time,
|
||||
end_time,
|
||||
duration,
|
||||
cache_path,
|
||||
)
|
||||
# else retain_days includes this segment
|
||||
else:
|
||||
self.store_segment(
|
||||
camera, start_time, end_time, duration, cache_path
|
||||
)
|
||||
|
||||
def store_segment(self, camera, start_time, end_time, duration, cache_path):
|
||||
directory = os.path.join(RECORD_DIR, start_time.strftime("%Y-%m/%d/%H"), camera)
|
||||
|
||||
if not os.path.exists(directory):
|
||||
os.makedirs(directory)
|
||||
|
||||
file_name = f"{start_time.strftime('%M.%S.mp4')}"
|
||||
file_path = os.path.join(directory, file_name)
|
||||
|
||||
try:
|
||||
start_frame = datetime.datetime.now().timestamp()
|
||||
# copy then delete is required when recordings are stored on some network drives
|
||||
shutil.copyfile(cache_path, file_path)
|
||||
logger.debug(
|
||||
f"Copied {file_path} in {datetime.datetime.now().timestamp()-start_frame} seconds."
|
||||
)
|
||||
os.remove(cache_path)
|
||||
|
||||
rand_id = "".join(
|
||||
@@ -128,11 +213,24 @@ class RecordingMaintainer(threading.Thread):
|
||||
end_time=end_time.timestamp(),
|
||||
duration=duration,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Unable to store recording segment {cache_path}")
|
||||
Path(cache_path).unlink(missing_ok=True)
|
||||
logger.error(e)
|
||||
|
||||
def run(self):
|
||||
# Check for new files every 5 seconds
|
||||
while not self.stop_event.wait(5):
|
||||
self.move_files()
|
||||
wait_time = 5
|
||||
while not self.stop_event.wait(wait_time):
|
||||
run_start = datetime.datetime.now().timestamp()
|
||||
try:
|
||||
self.move_files()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error occurred when attempting to maintain recording cache"
|
||||
)
|
||||
logger.error(e)
|
||||
wait_time = max(0, 5 - (datetime.datetime.now().timestamp() - run_start))
|
||||
|
||||
logger.info(f"Exiting recording maintenance...")
|
||||
|
||||
@@ -203,7 +301,11 @@ class RecordingCleanup(threading.Thread):
|
||||
events: Event = (
|
||||
Event.select()
|
||||
.where(
|
||||
Event.camera == camera, Event.end_time < expire_date, Event.has_clip
|
||||
Event.camera == camera,
|
||||
# need to ensure segments for all events starting
|
||||
# before the expire date are included
|
||||
Event.start_time < expire_date,
|
||||
Event.has_clip,
|
||||
)
|
||||
.order_by(Event.start_time)
|
||||
.objects()
|
||||
@@ -224,9 +326,9 @@ class RecordingCleanup(threading.Thread):
|
||||
keep = False
|
||||
break
|
||||
|
||||
# if the event 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
|
||||
if event.end_time >= recording.start_time:
|
||||
if event.end_time is None or event.end_time >= recording.start_time:
|
||||
keep = True
|
||||
break
|
||||
|
||||
@@ -267,17 +369,19 @@ class RecordingCleanup(threading.Thread):
|
||||
|
||||
# find all the recordings older than the oldest recording in the db
|
||||
try:
|
||||
oldest_recording = (
|
||||
Recordings.select().order_by(Recordings.start_time.desc()).get()
|
||||
)
|
||||
oldest_recording = Recordings.select().order_by(Recordings.start_time).get()
|
||||
|
||||
oldest_timestamp = oldest_recording.start_time
|
||||
p = Path(oldest_recording.path)
|
||||
oldest_timestamp = p.stat().st_mtime - 1
|
||||
except DoesNotExist:
|
||||
oldest_timestamp = datetime.datetime.now().timestamp()
|
||||
except FileNotFoundError:
|
||||
logger.warning(f"Unable to find file from recordings database: {p}")
|
||||
oldest_timestamp = datetime.datetime.now().timestamp()
|
||||
|
||||
logger.debug(f"Oldest recording in the db: {oldest_timestamp}")
|
||||
process = sp.run(
|
||||
["find", RECORD_DIR, "-type", "f", "-newermt", f"@{oldest_timestamp}"],
|
||||
["find", RECORD_DIR, "-type", "f", "!", "-newermt", f"@{oldest_timestamp}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
@@ -702,7 +702,11 @@ class TestConfig(unittest.TestCase):
|
||||
"inputs": [
|
||||
{
|
||||
"path": "rtsp://10.0.0.1:554/video",
|
||||
"roles": ["detect", "clips"],
|
||||
"roles": ["detect"],
|
||||
},
|
||||
{
|
||||
"path": "rtsp://10.0.0.1:554/video2",
|
||||
"roles": ["clips"],
|
||||
},
|
||||
]
|
||||
},
|
||||
@@ -717,6 +721,87 @@ class TestConfig(unittest.TestCase):
|
||||
|
||||
self.assertRaises(ValidationError, lambda: FrigateConfig(**config))
|
||||
|
||||
def test_fails_on_missing_role(self):
|
||||
|
||||
config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"cameras": {
|
||||
"back": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{
|
||||
"path": "rtsp://10.0.0.1:554/video",
|
||||
"roles": ["detect"],
|
||||
},
|
||||
{
|
||||
"path": "rtsp://10.0.0.1:554/video2",
|
||||
"roles": ["record"],
|
||||
},
|
||||
]
|
||||
},
|
||||
"detect": {
|
||||
"height": 1080,
|
||||
"width": 1920,
|
||||
"fps": 5,
|
||||
},
|
||||
"rtmp": {"enabled": True},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
frigate_config = FrigateConfig(**config)
|
||||
self.assertRaises(ValueError, lambda: frigate_config.runtime_config)
|
||||
|
||||
def test_works_on_missing_role_multiple_cams(self):
|
||||
|
||||
config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"rtmp": {"enabled": False},
|
||||
"cameras": {
|
||||
"back": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{
|
||||
"path": "rtsp://10.0.0.1:554/video",
|
||||
"roles": ["detect"],
|
||||
},
|
||||
{
|
||||
"path": "rtsp://10.0.0.1:554/video2",
|
||||
"roles": ["record"],
|
||||
},
|
||||
]
|
||||
},
|
||||
"detect": {
|
||||
"height": 1080,
|
||||
"width": 1920,
|
||||
"fps": 5,
|
||||
},
|
||||
},
|
||||
"cam2": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{
|
||||
"path": "rtsp://10.0.0.1:554/video",
|
||||
"roles": ["detect"],
|
||||
},
|
||||
{
|
||||
"path": "rtsp://10.0.0.1:554/video2",
|
||||
"roles": ["record"],
|
||||
},
|
||||
]
|
||||
},
|
||||
"detect": {
|
||||
"height": 1080,
|
||||
"width": 1920,
|
||||
"fps": 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
frigate_config = FrigateConfig(**config)
|
||||
runtime_config = frigate_config.runtime_config
|
||||
|
||||
def test_global_detect(self):
|
||||
|
||||
config = {
|
||||
@@ -958,6 +1043,34 @@ class TestConfig(unittest.TestCase):
|
||||
runtime_config = frigate_config.runtime_config
|
||||
assert runtime_config.cameras["back"].rtmp.enabled
|
||||
|
||||
def test_global_rtmp_default(self):
|
||||
|
||||
config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"rtmp": {"enabled": False},
|
||||
"cameras": {
|
||||
"back": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{
|
||||
"path": "rtsp://10.0.0.1:554/video",
|
||||
"roles": ["detect"],
|
||||
},
|
||||
{
|
||||
"path": "rtsp://10.0.0.1:554/video2",
|
||||
"roles": ["record"],
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
frigate_config = FrigateConfig(**config)
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config
|
||||
assert not runtime_config.cameras["back"].rtmp.enabled
|
||||
|
||||
def test_global_live(self):
|
||||
|
||||
config = {
|
||||
|
||||
27
frigate/test/test_reduce_boxes.py
Normal file
27
frigate/test/test_reduce_boxes.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import cv2
|
||||
import numpy as np
|
||||
from unittest import TestCase, main
|
||||
from frigate.video import box_overlaps, reduce_boxes
|
||||
|
||||
|
||||
class TestBoxOverlaps(TestCase):
|
||||
def test_overlap(self):
|
||||
assert box_overlaps((100, 100, 200, 200), (50, 50, 150, 150))
|
||||
|
||||
def test_overlap_2(self):
|
||||
assert box_overlaps((50, 50, 150, 150), (100, 100, 200, 200))
|
||||
|
||||
def test_no_overlap(self):
|
||||
assert not box_overlaps((100, 100, 200, 200), (250, 250, 350, 350))
|
||||
|
||||
|
||||
class TestReduceBoxes(TestCase):
|
||||
def test_cluster(self):
|
||||
clusters = reduce_boxes(
|
||||
[(144, 290, 221, 459), (225, 178, 426, 341), (343, 105, 584, 250)]
|
||||
)
|
||||
assert len(clusters) == 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(verbosity=2)
|
||||
@@ -191,7 +191,7 @@ def draw_box_with_label(
|
||||
|
||||
def calculate_region(frame_shape, xmin, ymin, xmax, ymax, multiplier=2):
|
||||
# size is the longest edge and divisible by 4
|
||||
size = int(max(xmax - xmin, ymax - ymin) // 4 * 4 * multiplier)
|
||||
size = int((max(xmax - xmin, ymax - ymin) * multiplier) // 4 * 4)
|
||||
# dont go any smaller than 300
|
||||
if size < 300:
|
||||
size = 300
|
||||
|
||||
160
frigate/video.py
160
frigate/video.py
@@ -3,18 +3,18 @@ import itertools
|
||||
import logging
|
||||
import multiprocessing as mp
|
||||
import queue
|
||||
import subprocess as sp
|
||||
import signal
|
||||
import subprocess as sp
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from setproctitle import setproctitle
|
||||
from typing import Dict, List
|
||||
|
||||
from cv2 import cv2
|
||||
import numpy as np
|
||||
from cv2 import cv2, reduce
|
||||
from setproctitle import setproctitle
|
||||
|
||||
from frigate.config import CameraConfig
|
||||
from frigate.config import CameraConfig, DetectConfig
|
||||
from frigate.edgetpu import RemoteObjectDetector
|
||||
from frigate.log import LogPipe
|
||||
from frigate.motion import MotionDetector
|
||||
@@ -23,8 +23,11 @@ from frigate.util import (
|
||||
EventsPerSecond,
|
||||
FrameManager,
|
||||
SharedMemoryFrameManager,
|
||||
area,
|
||||
calculate_region,
|
||||
clipped,
|
||||
intersection,
|
||||
intersection_over_union,
|
||||
listen,
|
||||
yuv_region_2_rgb,
|
||||
)
|
||||
@@ -72,7 +75,25 @@ def filtered(obj, objects_to_track, object_filters):
|
||||
|
||||
|
||||
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
|
||||
if cropped_frame.shape != (model_shape[0], model_shape[1], 3):
|
||||
@@ -364,6 +385,7 @@ def track_camera(
|
||||
frame_queue,
|
||||
frame_shape,
|
||||
model_shape,
|
||||
config.detect,
|
||||
frame_manager,
|
||||
motion_detector,
|
||||
object_detector,
|
||||
@@ -379,26 +401,36 @@ def track_camera(
|
||||
logger.info(f"{name}: exiting subprocess")
|
||||
|
||||
|
||||
def reduce_boxes(boxes):
|
||||
if len(boxes) == 0:
|
||||
return []
|
||||
reduced_boxes = cv2.groupRectangles(
|
||||
[list(b) for b in itertools.chain(boxes, boxes)], 1, 0.2
|
||||
)[0]
|
||||
return [tuple(b) for b in reduced_boxes]
|
||||
def box_overlaps(b1, b2):
|
||||
if b1[2] < b2[0] or b1[0] > b2[2] or b1[1] > b2[3] or b1[3] < b2[1]:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def reduce_boxes(boxes, iou_threshold=0.0):
|
||||
clusters = []
|
||||
|
||||
for box in boxes:
|
||||
matched = 0
|
||||
for cluster in clusters:
|
||||
if intersection_over_union(box, cluster) > iou_threshold:
|
||||
matched = 1
|
||||
cluster[0] = min(cluster[0], box[0])
|
||||
cluster[1] = min(cluster[1], box[1])
|
||||
cluster[2] = max(cluster[2], box[2])
|
||||
cluster[3] = max(cluster[3], box[3])
|
||||
|
||||
if not matched:
|
||||
clusters.append(list(box))
|
||||
|
||||
return [tuple(c) for c in clusters]
|
||||
|
||||
|
||||
# modified from https://stackoverflow.com/a/40795835
|
||||
def intersects_any(box_a, boxes):
|
||||
for box in boxes:
|
||||
if (
|
||||
box_a[2] < box[0]
|
||||
or box_a[0] > box[2]
|
||||
or box_a[1] > box[3]
|
||||
or box_a[3] < box[1]
|
||||
):
|
||||
continue
|
||||
return True
|
||||
if box_overlaps(box_a, box):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def detect(
|
||||
@@ -434,6 +466,7 @@ def process_frames(
|
||||
frame_queue: mp.Queue,
|
||||
frame_shape,
|
||||
model_shape,
|
||||
detect_config: DetectConfig,
|
||||
frame_manager: FrameManager,
|
||||
motion_detector: MotionDetector,
|
||||
object_detector: RemoteObjectDetector,
|
||||
@@ -487,11 +520,28 @@ def process_frames(
|
||||
# look for motion
|
||||
motion_boxes = motion_detector.detect(frame)
|
||||
|
||||
# only get the tracked object boxes that intersect with motion
|
||||
# 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)
|
||||
]
|
||||
|
||||
# get tracked object boxes that aren't stationary
|
||||
tracked_object_boxes = [
|
||||
obj["box"]
|
||||
for obj in object_tracker.tracked_objects.values()
|
||||
if intersects_any(obj["box"], motion_boxes)
|
||||
if not obj["id"] in stationary_object_ids
|
||||
]
|
||||
|
||||
# combine motion boxes with known locations of existing objects
|
||||
@@ -503,17 +553,25 @@ def process_frames(
|
||||
for a in combined_boxes
|
||||
]
|
||||
|
||||
# combine overlapping regions
|
||||
combined_regions = reduce_boxes(regions)
|
||||
|
||||
# re-compute regions
|
||||
# consolidate regions with heavy overlap
|
||||
regions = [
|
||||
calculate_region(frame_shape, a[0], a[1], a[2], a[3], 1.0)
|
||||
for a in combined_regions
|
||||
for a in reduce_boxes(regions, 0.4)
|
||||
]
|
||||
|
||||
# resize regions and detect
|
||||
detections = []
|
||||
# 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(
|
||||
@@ -582,14 +640,46 @@ def process_frames(
|
||||
if refining:
|
||||
refine_count += 1
|
||||
|
||||
# Limit to the detections overlapping with motion areas
|
||||
# to avoid picking up stationary background objects
|
||||
detections_with_motion = [
|
||||
d for d in detections if intersects_any(d[2], motion_boxes)
|
||||
]
|
||||
## 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]
|
||||
)
|
||||
|
||||
# now that we have refined our detections, we need to track objects
|
||||
object_tracker.match_and_update(frame_time, detections_with_motion)
|
||||
object_tracker.match_and_update(frame_time, consolidated_detections)
|
||||
|
||||
# add to the queue if not full
|
||||
if detected_objects_queue.full():
|
||||
|
||||
43
migrations/005_make_end_time_nullable.py
Normal file
43
migrations/005_make_end_time_nullable.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""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 Event
|
||||
|
||||
try:
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
SQL = pw.SQL
|
||||
|
||||
|
||||
def migrate(migrator, database, fake=False, **kwargs):
|
||||
migrator.drop_not_null(Event, "end_time")
|
||||
|
||||
|
||||
def rollback(migrator, database, fake=False, **kwargs):
|
||||
pass
|
||||
@@ -1,23 +1,26 @@
|
||||
import datetime
|
||||
import sys
|
||||
from typing_extensions import runtime
|
||||
|
||||
sys.path.append("/lab/frigate")
|
||||
|
||||
import json
|
||||
import logging
|
||||
import multiprocessing as mp
|
||||
import os
|
||||
import subprocess as sp
|
||||
import sys
|
||||
from unittest import TestCase, main
|
||||
|
||||
import click
|
||||
import csv
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from frigate.config import FRIGATE_CONFIG_SCHEMA, FrigateConfig
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.edgetpu import LocalObjectDetector
|
||||
from frigate.motion import MotionDetector
|
||||
from frigate.object_processing import CameraState
|
||||
from frigate.objects import ObjectTracker
|
||||
from frigate.util import (
|
||||
DictFrameManager,
|
||||
EventsPerSecond,
|
||||
SharedMemoryFrameManager,
|
||||
draw_box_with_label,
|
||||
@@ -96,20 +99,22 @@ class ProcessClip:
|
||||
ffmpeg_process.wait()
|
||||
ffmpeg_process.communicate()
|
||||
|
||||
def process_frames(self, objects_to_track=["person"], object_filters={}):
|
||||
def process_frames(
|
||||
self, object_detector, objects_to_track=["person"], object_filters={}
|
||||
):
|
||||
mask = np.zeros((self.frame_shape[0], self.frame_shape[1], 1), np.uint8)
|
||||
mask[:] = 255
|
||||
motion_detector = MotionDetector(
|
||||
self.frame_shape, mask, self.camera_config.motion
|
||||
)
|
||||
motion_detector = MotionDetector(self.frame_shape, self.camera_config.motion)
|
||||
motion_detector.save_images = False
|
||||
|
||||
object_detector = LocalObjectDetector(labels="/labelmap.txt")
|
||||
object_tracker = ObjectTracker(self.camera_config.detect)
|
||||
process_info = {
|
||||
"process_fps": mp.Value("d", 0.0),
|
||||
"detection_fps": mp.Value("d", 0.0),
|
||||
"detection_frame": mp.Value("d", 0.0),
|
||||
}
|
||||
|
||||
detection_enabled = mp.Value("d", 1)
|
||||
stop_event = mp.Event()
|
||||
model_shape = (self.config.model.height, self.config.model.width)
|
||||
|
||||
@@ -118,6 +123,7 @@ class ProcessClip:
|
||||
self.frame_queue,
|
||||
self.frame_shape,
|
||||
model_shape,
|
||||
self.camera_config.detect,
|
||||
self.frame_manager,
|
||||
motion_detector,
|
||||
object_detector,
|
||||
@@ -126,25 +132,16 @@ class ProcessClip:
|
||||
process_info,
|
||||
objects_to_track,
|
||||
object_filters,
|
||||
mask,
|
||||
detection_enabled,
|
||||
stop_event,
|
||||
exit_on_empty=True,
|
||||
)
|
||||
|
||||
def top_object(self, debug_path=None):
|
||||
obj_detected = False
|
||||
top_computed_score = 0.0
|
||||
|
||||
def handle_event(name, obj, frame_time):
|
||||
nonlocal obj_detected
|
||||
nonlocal top_computed_score
|
||||
if obj.computed_score > top_computed_score:
|
||||
top_computed_score = obj.computed_score
|
||||
if not obj.false_positive:
|
||||
obj_detected = True
|
||||
|
||||
self.camera_state.on("new", handle_event)
|
||||
self.camera_state.on("update", handle_event)
|
||||
def stats(self, debug_path=None):
|
||||
total_regions = 0
|
||||
total_motion_boxes = 0
|
||||
object_ids = set()
|
||||
total_frames = 0
|
||||
|
||||
while not self.detected_objects_queue.empty():
|
||||
(
|
||||
@@ -154,7 +151,8 @@ class ProcessClip:
|
||||
motion_boxes,
|
||||
regions,
|
||||
) = self.detected_objects_queue.get()
|
||||
if not debug_path is None:
|
||||
|
||||
if debug_path:
|
||||
self.save_debug_frame(
|
||||
debug_path, frame_time, current_tracked_objects.values()
|
||||
)
|
||||
@@ -162,10 +160,22 @@ class ProcessClip:
|
||||
self.camera_state.update(
|
||||
frame_time, current_tracked_objects, motion_boxes, regions
|
||||
)
|
||||
total_regions += len(regions)
|
||||
total_motion_boxes += len(motion_boxes)
|
||||
for id, obj in self.camera_state.tracked_objects.items():
|
||||
if not obj.false_positive:
|
||||
object_ids.add(id)
|
||||
|
||||
self.frame_manager.delete(self.camera_state.previous_frame_id)
|
||||
total_frames += 1
|
||||
|
||||
return {"object_detected": obj_detected, "top_score": top_computed_score}
|
||||
self.frame_manager.delete(self.camera_state.previous_frame_id)
|
||||
|
||||
return {
|
||||
"total_regions": total_regions,
|
||||
"total_motion_boxes": total_motion_boxes,
|
||||
"true_positive_objects": len(object_ids),
|
||||
"total_frames": total_frames,
|
||||
}
|
||||
|
||||
def save_debug_frame(self, debug_path, frame_time, tracked_objects):
|
||||
current_frame = cv2.cvtColor(
|
||||
@@ -178,7 +188,6 @@ class ProcessClip:
|
||||
for obj in tracked_objects:
|
||||
thickness = 2
|
||||
color = (0, 0, 175)
|
||||
|
||||
if obj["frame_time"] != frame_time:
|
||||
thickness = 1
|
||||
color = (255, 0, 0)
|
||||
@@ -221,10 +230,9 @@ class ProcessClip:
|
||||
@click.command()
|
||||
@click.option("-p", "--path", required=True, help="Path to clip or directory to test.")
|
||||
@click.option("-l", "--label", default="person", help="Label name to detect.")
|
||||
@click.option("-t", "--threshold", default=0.85, help="Threshold value for objects.")
|
||||
@click.option("-s", "--scores", default=None, help="File to save csv of top scores")
|
||||
@click.option("-o", "--output", default=None, help="File to save csv of data")
|
||||
@click.option("--debug-path", default=None, help="Path to output frames for debugging.")
|
||||
def process(path, label, threshold, scores, debug_path):
|
||||
def process(path, label, output, debug_path):
|
||||
clips = []
|
||||
if os.path.isdir(path):
|
||||
files = os.listdir(path)
|
||||
@@ -235,51 +243,78 @@ def process(path, label, threshold, scores, debug_path):
|
||||
|
||||
json_config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"detectors": {"coral": {"type": "edgetpu", "device": "usb"}},
|
||||
"cameras": {
|
||||
"camera": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{
|
||||
"path": "path.mp4",
|
||||
"global_args": "",
|
||||
"input_args": "",
|
||||
"global_args": "-hide_banner",
|
||||
"input_args": "-loglevel info",
|
||||
"roles": ["detect"],
|
||||
}
|
||||
]
|
||||
},
|
||||
"height": 1920,
|
||||
"width": 1080,
|
||||
"rtmp": {"enabled": False},
|
||||
"record": {"enabled": False},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
object_detector = LocalObjectDetector(labels="/labelmap.txt")
|
||||
|
||||
results = []
|
||||
for c in clips:
|
||||
logger.info(c)
|
||||
frame_shape = get_frame_shape(c)
|
||||
|
||||
json_config["cameras"]["camera"]["height"] = frame_shape[0]
|
||||
json_config["cameras"]["camera"]["width"] = frame_shape[1]
|
||||
json_config["cameras"]["camera"]["detect"] = {
|
||||
"height": frame_shape[0],
|
||||
"width": frame_shape[1],
|
||||
}
|
||||
json_config["cameras"]["camera"]["ffmpeg"]["inputs"][0]["path"] = c
|
||||
|
||||
config = FrigateConfig(config=FRIGATE_CONFIG_SCHEMA(json_config))
|
||||
frigate_config = FrigateConfig(**json_config)
|
||||
runtime_config = frigate_config.runtime_config
|
||||
|
||||
process_clip = ProcessClip(c, frame_shape, config)
|
||||
process_clip = ProcessClip(c, frame_shape, runtime_config)
|
||||
process_clip.load_frames()
|
||||
process_clip.process_frames(objects_to_track=[label])
|
||||
process_clip.process_frames(object_detector, objects_to_track=[label])
|
||||
|
||||
results.append((c, process_clip.top_object(debug_path)))
|
||||
results.append((c, process_clip.stats(debug_path)))
|
||||
|
||||
if not scores is None:
|
||||
with open(scores, "w") as writer:
|
||||
for result in results:
|
||||
writer.write(f"{result[0]},{result[1]['top_score']}\n")
|
||||
|
||||
positive_count = sum(1 for result in results if result[1]["object_detected"])
|
||||
positive_count = sum(
|
||||
1 for result in results if result[1]["true_positive_objects"] > 0
|
||||
)
|
||||
print(
|
||||
f"Objects were detected in {positive_count}/{len(results)}({positive_count/len(results)*100:.2f}%) clip(s)."
|
||||
)
|
||||
|
||||
if output:
|
||||
# now we will open a file for writing
|
||||
data_file = open(output, "w")
|
||||
|
||||
# create the csv writer object
|
||||
csv_writer = csv.writer(data_file)
|
||||
|
||||
# Counter variable used for writing
|
||||
# headers to the CSV file
|
||||
count = 0
|
||||
|
||||
for result in results:
|
||||
if count == 0:
|
||||
|
||||
# Writing headers of CSV file
|
||||
header = ["file"] + list(result[1].keys())
|
||||
csv_writer.writerow(header)
|
||||
count += 1
|
||||
|
||||
# Writing data of CSV file
|
||||
csv_writer.writerow([result[0]] + list(result[1].values()))
|
||||
|
||||
data_file.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
process()
|
||||
769
web/package-lock.json
generated
769
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,7 @@
|
||||
"@cycjimmy/jsmpeg-player": "^5.0.1",
|
||||
"date-fns": "^2.21.3",
|
||||
"idb-keyval": "^5.0.2",
|
||||
"immer": "^8.0.1",
|
||||
"immer": "^9.0.6",
|
||||
"preact": "^10.5.9",
|
||||
"preact-async-route": "^2.2.1",
|
||||
"preact-router": "^3.2.1",
|
||||
|
||||
@@ -121,12 +121,12 @@ describe('MqttProvider', () => {
|
||||
</MqttProvider>
|
||||
);
|
||||
await screen.findByTestId('data');
|
||||
expect(screen.getByTestId('front/detect/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"ON"}');
|
||||
expect(screen.getByTestId('front/recordings/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF"}');
|
||||
expect(screen.getByTestId('front/snapshots/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"ON"}');
|
||||
expect(screen.getByTestId('side/detect/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF"}');
|
||||
expect(screen.getByTestId('side/recordings/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF"}');
|
||||
expect(screen.getByTestId('side/snapshots/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF"}');
|
||||
expect(screen.getByTestId('front/detect/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"ON","retain":true}');
|
||||
expect(screen.getByTestId('front/recordings/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF","retain":true}');
|
||||
expect(screen.getByTestId('front/snapshots/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"ON","retain":true}');
|
||||
expect(screen.getByTestId('side/detect/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF","retain":true}');
|
||||
expect(screen.getByTestId('side/recordings/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF","retain":true}');
|
||||
expect(screen.getByTestId('side/snapshots/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF","retain":true}');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -42,9 +42,9 @@ export function MqttProvider({
|
||||
useEffect(() => {
|
||||
Object.keys(config.cameras).forEach((camera) => {
|
||||
const { name, record, detect, snapshots } = config.cameras[camera];
|
||||
dispatch({ topic: `${name}/recordings/state`, payload: record.enabled ? 'ON' : 'OFF' });
|
||||
dispatch({ topic: `${name}/detect/state`, payload: detect.enabled ? 'ON' : 'OFF' });
|
||||
dispatch({ topic: `${name}/snapshots/state`, payload: snapshots.enabled ? 'ON' : 'OFF' });
|
||||
dispatch({ topic: `${name}/recordings/state`, payload: record.enabled ? 'ON' : 'OFF', retain: true });
|
||||
dispatch({ topic: `${name}/detect/state`, payload: detect.enabled ? 'ON' : 'OFF', retain: true });
|
||||
dispatch({ topic: `${name}/snapshots/state`, payload: snapshots.enabled ? 'ON' : 'OFF', retain: true });
|
||||
});
|
||||
}, [config]);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { baseUrl } from '../api/baseUrl';
|
||||
import { useRef, useEffect } from 'preact/hooks';
|
||||
import JSMpeg from '@cycjimmy/jsmpeg-player';
|
||||
|
||||
export default function JSMpegPlayer({ camera }) {
|
||||
export default function JSMpegPlayer({ camera, width, height }) {
|
||||
const playerRef = useRef();
|
||||
const url = `${baseUrl.replace(/^http/, 'ws')}/live/${camera}`
|
||||
|
||||
@@ -32,6 +32,6 @@ export default function JSMpegPlayer({ camera }) {
|
||||
}, [url]);
|
||||
|
||||
return (
|
||||
<div ref={playerRef} class="jsmpeg" />
|
||||
<div ref={playerRef} class="jsmpeg" style={`max-height: ${height}px; max-width: ${width}px`} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export default function Camera({ camera }) {
|
||||
const [viewMode, setViewMode] = useState('live');
|
||||
|
||||
const cameraConfig = config?.cameras[camera];
|
||||
const liveWidth = Math.round(cameraConfig.live.height * (cameraConfig.detect.width / cameraConfig.detect.height))
|
||||
const [options, setOptions] = usePersistence(`${camera}-feed`, emptyObject);
|
||||
|
||||
const handleSetOption = useCallback(
|
||||
@@ -87,7 +88,7 @@ export default function Camera({ camera }) {
|
||||
player = (
|
||||
<Fragment>
|
||||
<div>
|
||||
<JSMpegPlayer camera={camera} />
|
||||
<JSMpegPlayer camera={camera} width={liveWidth} height={cameraConfig.live.height} />
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
@@ -99,7 +99,7 @@ export default function Event({ eventId, close, scrollRef }) {
|
||||
}
|
||||
|
||||
const startime = new Date(data.start_time * 1000);
|
||||
const endtime = new Date(data.end_time * 1000);
|
||||
const endtime = data.end_time ? new Date(data.end_time * 1000) : null;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex md:flex-row justify-between flex-wrap flex-col">
|
||||
@@ -155,7 +155,7 @@ export default function Event({ eventId, close, scrollRef }) {
|
||||
<Tr index={1}>
|
||||
<Td>Timeframe</Td>
|
||||
<Td>
|
||||
{startime.toLocaleString()} – {endtime.toLocaleString()}
|
||||
{startime.toLocaleString()}{endtime === null ? ` – ${endtime.toLocaleString()}`:''}
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
@@ -186,7 +186,7 @@ export default function Event({ eventId, close, scrollRef }) {
|
||||
},
|
||||
],
|
||||
poster: data.has_snapshot
|
||||
? `${apiHost}/clips/${data.camera}-${eventId}.jpg`
|
||||
? `${apiHost}/api/events/${eventId}/snapshot.jpg`
|
||||
: `data:image/jpeg;base64,${data.thumbnail}`,
|
||||
}}
|
||||
seekOptions={{ forward: 10, back: 5 }}
|
||||
|
||||
@@ -42,7 +42,7 @@ const EventsRow = memo(
|
||||
);
|
||||
|
||||
const start = new Date(parseInt(startTime * 1000, 10));
|
||||
const end = new Date(parseInt(endTime * 1000, 10));
|
||||
const end = endTime ? new Date(parseInt(endTime * 1000, 10)) : null;
|
||||
|
||||
return (
|
||||
<Tbody reference={innerRef}>
|
||||
@@ -102,7 +102,7 @@ const EventsRow = memo(
|
||||
</Td>
|
||||
<Td>{start.toLocaleDateString()}</Td>
|
||||
<Td>{start.toLocaleTimeString()}</Td>
|
||||
<Td>{end.toLocaleTimeString()}</Td>
|
||||
<Td>{end === null ? 'In progress' : end.toLocaleTimeString()}</Td>
|
||||
</Tr>
|
||||
{viewEvent === id ? (
|
||||
<Tr className="border-b-1">
|
||||
|
||||
@@ -13,7 +13,7 @@ describe('Camera Route', () => {
|
||||
mockSetOptions = jest.fn();
|
||||
mockUsePersistence = jest.spyOn(Context, 'usePersistence').mockImplementation(() => [{}, mockSetOptions]);
|
||||
jest.spyOn(Api, 'useConfig').mockImplementation(() => ({
|
||||
data: { cameras: { front: { name: 'front', objects: { track: ['taco', 'cat', 'dog'] } } } },
|
||||
data: { cameras: { front: { name: 'front', detect: {width: 1280, height: 720}, live: {height: 720}, objects: { track: ['taco', 'cat', 'dog'] } } } },
|
||||
}));
|
||||
jest.spyOn(Api, 'useApiHost').mockImplementation(() => 'http://base-url.local:5000');
|
||||
jest.spyOn(AutoUpdatingCameraImage, 'default').mockImplementation(({ searchParams }) => {
|
||||
|
||||
Reference in New Issue
Block a user