Compare commits
50 Commits
v0.8.0-bet
...
v0.8.0-rc1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91628bd5d8 | ||
|
|
b10b64bf57 | ||
|
|
749c34be9f | ||
|
|
8cfdfab985 | ||
|
|
ef25f8a31e | ||
|
|
2a0551a08a | ||
|
|
0b80419f15 | ||
|
|
0dc81117aa | ||
|
|
49b29d72a7 | ||
|
|
21ece238ff | ||
|
|
f6ba3f2daa | ||
|
|
bb0d3cb59a | ||
|
|
ca9b6d6c5c | ||
|
|
3103ad2bfe | ||
|
|
eab3998ad0 | ||
|
|
a3dfd3a8e0 | ||
|
|
f1c3087775 | ||
|
|
1be91ed3f2 | ||
|
|
fd83c4f229 | ||
|
|
de99221ad5 | ||
|
|
6892ce56ac | ||
|
|
41cea6f62e | ||
|
|
4bbffa97df | ||
|
|
614f8abfef | ||
|
|
14289b5fd1 | ||
|
|
4164beff1c | ||
|
|
9b3ab486de | ||
|
|
232a49814a | ||
|
|
6c61f0b135 | ||
|
|
c572cec253 | ||
|
|
d4941f2a5f | ||
|
|
bf5ec2f65f | ||
|
|
f8e21584b6 | ||
|
|
3cba83f84b | ||
|
|
dcb4255d7e | ||
|
|
9fc3c0dc2f | ||
|
|
a78830b48e | ||
|
|
949fbadcdc | ||
|
|
12c9e63b13 | ||
|
|
157b230702 | ||
|
|
c69299d659 | ||
|
|
285d630770 | ||
|
|
b9318092f4 | ||
|
|
905c361d52 | ||
|
|
4443abbc49 | ||
|
|
dabb36ad93 | ||
|
|
2bc8736fd9 | ||
|
|
e9b3b09cc2 | ||
|
|
ca337c32b4 | ||
|
|
24b8bd7c85 |
7
.gitignore
vendored
@@ -1,8 +1,11 @@
|
||||
*.pyc
|
||||
.DS_Store
|
||||
*.pyc
|
||||
debug
|
||||
.vscode
|
||||
config/config.yml
|
||||
models
|
||||
*.mp4
|
||||
*.db
|
||||
frigate/version.py
|
||||
frigate/version.py
|
||||
web/build
|
||||
web/node_modules
|
||||
|
||||
31
Makefile
@@ -1,54 +1,59 @@
|
||||
default_target: amd64_frigate
|
||||
|
||||
COMMIT_HASH := $(shell git log -1 --pretty=format:"%h")
|
||||
COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1)
|
||||
|
||||
version:
|
||||
echo "VERSION='0.8.0-$(COMMIT_HASH)'" > frigate/version.py
|
||||
|
||||
web:
|
||||
docker build --tag frigate-web --file docker/Dockerfile.web web/
|
||||
|
||||
amd64_wheels:
|
||||
docker build --tag blakeblackshear/frigate-wheels:amd64 --file docker/Dockerfile.wheels .
|
||||
docker build --tag blakeblackshear/frigate-wheels:1.0.0-amd64 --file docker/Dockerfile.wheels .
|
||||
|
||||
amd64_ffmpeg:
|
||||
docker build --tag blakeblackshear/frigate-ffmpeg:1.1.0-amd64 --file docker/Dockerfile.ffmpeg.amd64 .
|
||||
|
||||
amd64_frigate: version
|
||||
docker build --tag frigate-base --build-arg ARCH=amd64 --build-arg FFMPEG_VERSION=1.1.0 --file docker/Dockerfile.base .
|
||||
amd64_frigate: version web
|
||||
docker build --tag frigate-base --build-arg ARCH=amd64 --build-arg FFMPEG_VERSION=1.1.0 --build-arg WHEELS_VERSION=1.0.0 --file docker/Dockerfile.base .
|
||||
docker build --tag frigate --file docker/Dockerfile.amd64 .
|
||||
|
||||
amd64_all: amd64_wheels amd64_ffmpeg amd64_frigate
|
||||
|
||||
amd64nvidia_wheels:
|
||||
docker build --tag blakeblackshear/frigate-wheels:amd64nvidia --file docker/Dockerfile.wheels .
|
||||
docker build --tag blakeblackshear/frigate-wheels:1.0.0-amd64nvidia --file docker/Dockerfile.wheels .
|
||||
|
||||
amd64nvidia_ffmpeg:
|
||||
docker build --tag blakeblackshear/frigate-ffmpeg:1.0.0-amd64nvidia --file docker/Dockerfile.ffmpeg.amd64nvidia .
|
||||
|
||||
amd64nvidia_frigate: version
|
||||
docker build --tag frigate-base --build-arg ARCH=amd64nvidia --build-arg FFMPEG_VERSION=1.0.0 --file docker/Dockerfile.base .
|
||||
amd64nvidia_frigate: version web
|
||||
docker build --tag frigate-base --build-arg ARCH=amd64nvidia --build-arg FFMPEG_VERSION=1.0.0 --build-arg WHEELS_VERSION=1.0.0 --file docker/Dockerfile.base .
|
||||
docker build --tag frigate --file docker/Dockerfile.amd64nvidia .
|
||||
|
||||
amd64nvidia_all: amd64nvidia_wheels amd64nvidia_ffmpeg amd64nvidia_frigate
|
||||
|
||||
aarch64_wheels:
|
||||
docker build --tag blakeblackshear/frigate-wheels:aarch64 --file docker/Dockerfile.wheels.aarch64 .
|
||||
docker build --tag blakeblackshear/frigate-wheels:1.0.0-aarch64 --file docker/Dockerfile.wheels .
|
||||
|
||||
aarch64_ffmpeg:
|
||||
docker build --tag blakeblackshear/frigate-ffmpeg:1.0.0-aarch64 --file docker/Dockerfile.ffmpeg.aarch64 .
|
||||
|
||||
aarch64_frigate: version
|
||||
docker build --tag frigate-base --build-arg ARCH=aarch64 --build-arg FFMPEG_VERSION=1.0.0 --file docker/Dockerfile.base .
|
||||
aarch64_frigate: version web
|
||||
docker build --tag frigate-base --build-arg ARCH=aarch64 --build-arg FFMPEG_VERSION=1.0.0 --build-arg WHEELS_VERSION=1.0.0 --file docker/Dockerfile.base .
|
||||
docker build --tag frigate --file docker/Dockerfile.aarch64 .
|
||||
|
||||
armv7_all: armv7_wheels armv7_ffmpeg armv7_frigate
|
||||
|
||||
armv7_wheels:
|
||||
docker build --tag blakeblackshear/frigate-wheels:armv7 --file docker/Dockerfile.wheels .
|
||||
docker build --tag blakeblackshear/frigate-wheels:1.0.0-armv7 --file docker/Dockerfile.wheels .
|
||||
|
||||
armv7_ffmpeg:
|
||||
docker build --tag blakeblackshear/frigate-ffmpeg:1.0.0-armv7 --file docker/Dockerfile.ffmpeg.armv7 .
|
||||
|
||||
armv7_frigate: version
|
||||
docker build --tag frigate-base --build-arg ARCH=armv7 --build-arg FFMPEG_VERSION=1.0.0 --file docker/Dockerfile.base .
|
||||
armv7_frigate: version web
|
||||
docker build --tag frigate-base --build-arg ARCH=armv7 --build-arg FFMPEG_VERSION=1.0.0 --build-arg WHEELS_VERSION=1.0.0 --file docker/Dockerfile.base .
|
||||
docker build --tag frigate --file docker/Dockerfile.armv7 .
|
||||
|
||||
armv7_all: armv7_wheels armv7_ffmpeg armv7_frigate
|
||||
|
||||
.PHONY: web
|
||||
|
||||
172
README.md
@@ -33,10 +33,12 @@ Use of a [Google Coral Accelerator](https://coral.ai/products/) is optional, but
|
||||
- [Object Filters](#object-filters)
|
||||
- [Masks](#masks)
|
||||
- [Zones](#zones)
|
||||
- [Recording Clips (save_clips)](#recording-clips)
|
||||
- [Recording Clips (clips)](#recording-clips)
|
||||
- [Snapshots (snapshots)](#snapshots)
|
||||
- [24/7 Recordings (record)](#247-recordings)
|
||||
- [RTMP Streams (rtmp)](#rtmp-streams)
|
||||
- [Integration with HomeAssistant](#integration-with-homeassistant)
|
||||
- [Web UI](#web-ui)
|
||||
- [MQTT Topics](#mqtt-topics)
|
||||
- [HTTP Endpoints](#http-endpoints)
|
||||
- [Custom Models](#custom-models)
|
||||
@@ -101,7 +103,7 @@ services:
|
||||
- type: tmpfs # Optional: 1GB of memory, reduces SSD/SD Card wear
|
||||
target: /tmp/cache
|
||||
tmpfs:
|
||||
size: 100000000
|
||||
size: 1000000000
|
||||
ports:
|
||||
- "5000:5000"
|
||||
- "1935:1935" # RTMP feeds
|
||||
@@ -114,7 +116,7 @@ If you can't use docker compose, you can run the container with something simila
|
||||
docker run --rm \
|
||||
--name frigate \
|
||||
--privileged \
|
||||
--mount type=tmpfs,target=/tmp/cache,tmpfs-size=100000000 \
|
||||
--mount type=tmpfs,target=/tmp/cache,tmpfs-size=1000000000 \
|
||||
-v /dev/bus/usb:/dev/bus/usb \
|
||||
-v <path_to_directory_for_clips>:/media/frigate/clips \
|
||||
-v <path_to_directory_for_recordings>:/media/frigate/recordings \
|
||||
@@ -180,7 +182,9 @@ cameras:
|
||||
height: 720
|
||||
fps: 5
|
||||
```
|
||||
Here are all the configuration options:
|
||||
Here are all configuration options.
|
||||
|
||||
**Please do not copy all of this as your starting configuration. Optional configuration options should not be included in your config unless you need to change from the default values.**
|
||||
```yaml
|
||||
# Optional: Logging configuration
|
||||
logger:
|
||||
@@ -190,6 +194,12 @@ logger:
|
||||
logs:
|
||||
frigate.mqtt: error
|
||||
|
||||
# Optional: Environment variables
|
||||
# This section can be used to set environment variables for those unable to modify the environment
|
||||
# of the container (ie. within Hass.io)
|
||||
environment_vars:
|
||||
EXAMPLE_VAR: value
|
||||
|
||||
# Optional: database configuration
|
||||
database:
|
||||
# Optional: database path
|
||||
@@ -197,7 +207,6 @@ database:
|
||||
path: /media/frigate/clips/frigate.db
|
||||
|
||||
# Optional: detectors configuration
|
||||
# USB Coral devices will be auto detected with CPU fallback
|
||||
detectors:
|
||||
# Required: name of the detector
|
||||
coral:
|
||||
@@ -235,9 +244,21 @@ mqtt:
|
||||
# NOTE: Environment variables that begin with 'FRIGATE_' may be referenced in {}.
|
||||
# eg. password: '{FRIGATE_MQTT_PASSWORD}'
|
||||
password: password
|
||||
# Optional: interval in seconds for publishing stats (default: shown below)
|
||||
stats_interval: 60
|
||||
|
||||
# Optional: Global configuration for the jpg snapshots written to the clips directory for each event
|
||||
snapshots:
|
||||
# Optional: Retention settings (default: shown below)
|
||||
retain:
|
||||
# Required: Default retention days (default: shown below)
|
||||
default: 10
|
||||
# Optional: Per object retention days
|
||||
objects:
|
||||
person: 15
|
||||
|
||||
# Optional: Global configuration for saving clips
|
||||
save_clips:
|
||||
clips:
|
||||
# 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 cache
|
||||
# will begin to expire and the resulting clip will be the last x seconds of the event.
|
||||
@@ -319,7 +340,7 @@ motion:
|
||||
# Lower values result in less CPU, but small changes may not register as motion.
|
||||
frame_height: 180
|
||||
|
||||
# Optional: Global detecttion settings. These may also be defined at the camera level.
|
||||
# Optional: Global detection settings. These may also be defined at the camera level.
|
||||
# ADVANCED: Most users will not need to set these values in their config
|
||||
detect:
|
||||
# Optional: Number of frames without a detection before frigate considers an object to be gone. (default: double the frame rate)
|
||||
@@ -368,10 +389,11 @@ cameras:
|
||||
# Frigate will attempt to autodetect if not specified.
|
||||
fps: 5
|
||||
|
||||
# Optional: list of motion masks
|
||||
# NOTE: see docs for more detailed info on creating masks
|
||||
mask:
|
||||
- poly,0,900,1080,900,1080,1920,0,1920
|
||||
# Optional: camera level motion config
|
||||
motion:
|
||||
# Optional: motion mask
|
||||
# NOTE: see docs for more detailed info on creating masks
|
||||
mask: 0,900,1080,900,1080,1920,0,1920
|
||||
|
||||
# Optional: timeout for highest scoring image before allowing it
|
||||
# to be replaced by a newer image. (default: shown below)
|
||||
@@ -394,12 +416,21 @@ cameras:
|
||||
max_area: 100000
|
||||
threshold: 0.7
|
||||
|
||||
# Optional: Camera level detect settings
|
||||
detect:
|
||||
# Optional: enables detection for the camera (default: True)
|
||||
# This value can be set via MQTT and will be updated in startup based on retained value
|
||||
enabled: True
|
||||
# Optional: Number of frames without a detection before frigate considers an object to be gone. (default: double the frame rate)
|
||||
max_disappeared: 10
|
||||
|
||||
# Optional: save clips configuration
|
||||
# NOTE: This feature does not work if you have added "-vsync drop" in your input params.
|
||||
# This will only work for camera feeds that can be copied into the mp4 container format without
|
||||
# encoding such as h264. It may not work for some types of streams.
|
||||
save_clips:
|
||||
clips:
|
||||
# Required: enables clips for the camera (default: shown below)
|
||||
# This value can be set via MQTT and will be updated in startup based on retained value
|
||||
enabled: False
|
||||
# Optional: Number of seconds before the event to include in the clips (default: shown below)
|
||||
pre_capture: 5
|
||||
@@ -428,21 +459,43 @@ cameras:
|
||||
# Required: Enable the live stream (default: True)
|
||||
enabled: True
|
||||
|
||||
# Optional: Configuration for the snapshots in the debug view and mqtt
|
||||
# Optional: Configuration for the jpg snapshots written to the clips directory for each event
|
||||
snapshots:
|
||||
# Optional: Enable writing jpg snapshot to /media/frigate/clips (default: shown below)
|
||||
# This value can be set via MQTT and will be updated in startup based on retained value
|
||||
enabled: False
|
||||
# Optional: print a timestamp on the snapshots (default: shown below)
|
||||
show_timestamp: True
|
||||
# Optional: draw zones on the debug mjpeg feed (default: shown below)
|
||||
draw_zones: False
|
||||
# Optional: draw bounding boxes on the mqtt snapshots (default: shown below)
|
||||
draw_bounding_boxes: True
|
||||
# Optional: crop the snapshot to the detection region (default: shown below)
|
||||
crop_to_region: True
|
||||
# Optional: height to resize the snapshot to (default: shown below)
|
||||
# NOTE: 175px is optimized for thumbnails in the homeassistant media browser
|
||||
timestamp: False
|
||||
# Optional: draw bounding box on the snapshots (default: shown below)
|
||||
bounding_box: False
|
||||
# Optional: crop the snapshot (default: shown below)
|
||||
crop: False
|
||||
# Optional: height to resize the snapshot to (default: original size)
|
||||
height: 175
|
||||
# Optional: Camera override for retention settings (default: global values)
|
||||
retain:
|
||||
# Required: Default retention days (default: shown below)
|
||||
default: 10
|
||||
# Optional: Per object retention days
|
||||
objects:
|
||||
person: 15
|
||||
|
||||
# Optional: Camera level object filters config. If defined, this is used instead of the global config.
|
||||
# Optional: Configuration for the jpg snapshots published via MQTT
|
||||
mqtt:
|
||||
# Optional: Enable publishing snapshot via mqtt for camera (default: shown below)
|
||||
# NOTE: Only applies to publishing image data to MQTT via 'frigate/<camera_name>/<object_name>/snapshot'.
|
||||
# All other messages will still be published.
|
||||
enabled: True
|
||||
# Optional: print a timestamp on the snapshots (default: shown below)
|
||||
timestamp: True
|
||||
# Optional: draw bounding box on the snapshots (default: shown below)
|
||||
bounding_box: True
|
||||
# Optional: crop the snapshot (default: shown below)
|
||||
crop: True
|
||||
# Optional: height to resize the snapshot to (default: shown below)
|
||||
height: 270
|
||||
|
||||
# Optional: Camera level object filters config.
|
||||
objects:
|
||||
track:
|
||||
- person
|
||||
@@ -453,6 +506,9 @@ cameras:
|
||||
max_area: 100000
|
||||
min_score: 0.5
|
||||
threshold: 0.7
|
||||
# Optional: mask to prevent this object type from being detected in certain areas (default: no mask)
|
||||
# Checks based on the bottom center of the bounding box of the object
|
||||
mask: 0,0,1000,0,1000,200,0,200
|
||||
```
|
||||
[Back to top](#documentation)
|
||||
|
||||
@@ -557,7 +613,7 @@ Nvidia GPU based decoding via NVDEC is supported, but requires special configura
|
||||
[Back to top](#documentation)
|
||||
|
||||
## Detectors
|
||||
By default Frigate will look for a USB Coral device and fall back to the CPU if it cannot be found. If you have PCI or multiple Coral devices, you need to configure your detector devices in the config file. When using multiple detectors, they run in dedicated processes, but pull from a common queue of requested detections across all cameras.
|
||||
The default config will look for a USB Coral device. If you do not have a Coral, you will need to configure a CPU detector. If you have PCI or multiple Coral devices, you need to configure your detector devices in the config file. When using multiple detectors, they run in dedicated processes, but pull from a common queue of requested detections across all cameras.
|
||||
|
||||
Frigate supports `edgetpu` and `cpu` as detector types. The device value should be specified according to the [Documentation for the TensorFlow Lite Python API](https://coral.ai/docs/edgetpu/multiple-edgetpu/#using-the-tensorflow-lite-python-api).
|
||||
|
||||
@@ -680,6 +736,10 @@ If you are storing your clips on a network share (SMB, NFS, etc), you may get a
|
||||
- `post_capture`: Defines how much time should be included in the clip after the end of the event. Defaults to 5 seconds.
|
||||
- `objects`: List of object types to save clips for. Object types here must be listed for tracking at the camera or global configuration. Defaults to all tracked objects.
|
||||
|
||||
[Back to top](#documentation)
|
||||
|
||||
## Snapshots
|
||||
Frigate can save a snapshot image to `/media/frigate/clips` for each event named as `<camera>-<id>.jpg`.
|
||||
|
||||
[Back to top](#documentation)
|
||||
|
||||
@@ -730,7 +790,7 @@ automation:
|
||||
data_template:
|
||||
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"]}}.jpg?format=android'
|
||||
image: 'https://your.public.hass.address.com/api/frigate/notifications/{{trigger.payload_json["after"]["id"]}}/thumbnail.jpg?format=android'
|
||||
tag: '{{trigger.payload_json["after"]["id"]}}'
|
||||
```
|
||||
Note that the image url has `?format=android`. This adjusts the aspect ratio to be idea for android notifications. For iOS optimized snapshots, no format parameter needs to be passed.
|
||||
@@ -739,10 +799,16 @@ You can find some additional examples for notifications [here](docs/notification
|
||||
|
||||
[Back to top](#documentation)
|
||||
|
||||
## Web UI
|
||||
Frigate comes bundled with a simple web ui that supports the following:
|
||||
- Show cameras
|
||||
- Browse events
|
||||
- Mask helper
|
||||
|
||||
## HTTP Endpoints
|
||||
A web server is available on port 5000 with the following endpoints.
|
||||
|
||||
### `/<camera_name>`
|
||||
### `/api/<camera_name>`
|
||||
An mjpeg stream for debugging. Keep in mind the mjpeg endpoint is for debugging only and will put additional load on the system when in use.
|
||||
|
||||
Accepts the following query string parameters:
|
||||
@@ -759,14 +825,14 @@ Accepts the following query string parameters:
|
||||
|
||||
You can access a higher resolution mjpeg stream by appending `h=height-in-pixels` to the endpoint. For example `http://localhost:5000/back?h=1080`. You can also increase the FPS by appending `fps=frame-rate` to the URL such as `http://localhost:5000/back?fps=10` or both with `?fps=10&h=1000`.
|
||||
|
||||
### `/<camera_name>/<object_name>/best.jpg[?h=300&crop=1]`
|
||||
### `/api/<camera_name>/<object_name>/best.jpg[?h=300&crop=1]`
|
||||
The best snapshot for any object type. It is a full resolution image by default.
|
||||
|
||||
Example parameters:
|
||||
- `h=300`: resizes the image to 300 pixes tall
|
||||
- `crop=1`: crops the image to the region of the detection rather than returning the entire image
|
||||
|
||||
### `/<camera_name>/latest.jpg[?h=300]`
|
||||
### `/api/<camera_name>/latest.jpg[?h=300]`
|
||||
The most recent frame that frigate has finished processing. It is a full resolution image by default.
|
||||
|
||||
Accepts the following query string parameters:
|
||||
@@ -783,7 +849,7 @@ Accepts the following query string parameters:
|
||||
Example parameters:
|
||||
- `h=300`: resizes the image to 300 pixes tall
|
||||
|
||||
### `/stats`
|
||||
### `/api/stats`
|
||||
Contains some granular debug info that can be used for sensors in HomeAssistant.
|
||||
|
||||
Sample response:
|
||||
@@ -842,17 +908,22 @@ Sample response:
|
||||
***************/
|
||||
"pid": 25321
|
||||
}
|
||||
},
|
||||
"service": {
|
||||
/* Uptime in seconds */
|
||||
"uptime": 10,
|
||||
"version": "0.8.0-8883709"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `/config`
|
||||
### `/api/config`
|
||||
A json representation of your configuration
|
||||
|
||||
### `/version`
|
||||
### `/api/version`
|
||||
Version info
|
||||
|
||||
### `/events`
|
||||
### `/api/events`
|
||||
Events from the database. Accepts the following query string parameters:
|
||||
|param|Type|Description|
|
||||
|----|-----|--|
|
||||
@@ -862,14 +933,22 @@ Events from the database. Accepts the following query string parameters:
|
||||
|`label`|str|Label name|
|
||||
|`zone`|str|Zone name|
|
||||
|`limit`|int|Limit the number of events returned|
|
||||
|`has_snapshot`|int|Filter to events that have snapshots (0 or 1)|
|
||||
|`has_clip`|int|Filter to events that have clips (0 or 1)|
|
||||
|
||||
### `/events/summary`
|
||||
### `/api/events/summary`
|
||||
Returns summary data for events in the database. Used by the HomeAssistant integration.
|
||||
|
||||
### `/events/<id>`
|
||||
### `/api/events/<id>`
|
||||
Returns data for a single event.
|
||||
### `/events/<id>/snapshot.jpg`
|
||||
Returns a snapshot for the event id optimized for notifications. Works while the event is in progress and after completion. Passing `?format=android` will convert the thumbnail to 2:1 aspect ratio.
|
||||
### `/api/events/<id>/thumbnail.jpg`
|
||||
Returns a thumbnail for the event id optimized for notifications. Works while the event is in progress and after completion. Passing `?format=android` will convert the thumbnail to 2:1 aspect ratio.
|
||||
|
||||
### `/clips/<camera>-<id>.mp4`
|
||||
Video clip for the given camera and event id.
|
||||
|
||||
### `/clips/<camera>-<id>.jpg`
|
||||
JPG snapshot for the given camera and event id.
|
||||
|
||||
[Back to top](#documentation)
|
||||
|
||||
@@ -898,6 +977,7 @@ Message published for each changed event. The first message is published when th
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"type": "update", // new, update, or end
|
||||
"before": {
|
||||
"id": "1607123955.475377-mxklsc",
|
||||
"camera": "front_door",
|
||||
@@ -966,6 +1046,26 @@ Message published for each changed event. The first message is published when th
|
||||
}
|
||||
```
|
||||
|
||||
### `frigate/stats`
|
||||
Same data available at `/api/stats` published at a configurable interval.
|
||||
|
||||
### `frigate/<camera_name>/detect/set`
|
||||
Topic to turn detection for a camera on and off. Expected values are `ON` and `OFF`.
|
||||
|
||||
### `frigate/<camera_name>/detect/state`
|
||||
Topic with current state of detection for a camera. Published values are `ON` and `OFF`.
|
||||
|
||||
### `frigate/<camera_name>/clips/set`
|
||||
Topic to turn clips for a camera on and off. Expected values are `ON` and `OFF`.
|
||||
|
||||
### `frigate/<camera_name>/clips/state`
|
||||
Topic with current state of clips for a camera. Published values are `ON` and `OFF`.
|
||||
### `frigate/<camera_name>/snapshots/set`
|
||||
Topic to turn snapshots for a camera on and off. Expected values are `ON` and `OFF`.
|
||||
|
||||
### `frigate/<camera_name>/snapshots/state`
|
||||
Topic with current state of snapshots for a camera. Published values are `ON` and `OFF`.
|
||||
|
||||
[Back to top](#documentation)
|
||||
|
||||
## Custom Models
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
ARG ARCH=amd64
|
||||
ARG WHEELS_VERSION
|
||||
ARG FFMPEG_VERSION
|
||||
FROM blakeblackshear/frigate-wheels:${ARCH} as wheels
|
||||
FROM blakeblackshear/frigate-wheels:${WHEELS_VERSION}-${ARCH} as wheels
|
||||
FROM blakeblackshear/frigate-ffmpeg:${FFMPEG_VERSION}-${ARCH} as ffmpeg
|
||||
FROM frigate-web as web
|
||||
|
||||
FROM ubuntu:20.04
|
||||
LABEL maintainer "blakeb@blakeshome.com"
|
||||
@@ -30,7 +32,7 @@ RUN apt-get -qq update \
|
||||
&& (apt-get autoremove -y; apt-get autoclean -y)
|
||||
|
||||
RUN pip3 install \
|
||||
peewee \
|
||||
peewee_migrate \
|
||||
zeroconf \
|
||||
voluptuous
|
||||
|
||||
@@ -43,6 +45,9 @@ RUN wget -q https://github.com/google-coral/test_data/raw/master/ssdlite_mobiled
|
||||
|
||||
WORKDIR /opt/frigate/
|
||||
ADD frigate frigate/
|
||||
ADD migrations migrations/
|
||||
|
||||
COPY --from=web /opt/frigate/build web/
|
||||
|
||||
COPY run.sh /run.sh
|
||||
RUN chmod +x /run.sh
|
||||
|
||||
9
docker/Dockerfile.web
Normal file
@@ -0,0 +1,9 @@
|
||||
ARG NODE_VERSION=14.0
|
||||
|
||||
FROM node:${NODE_VERSION}
|
||||
|
||||
WORKDIR /opt/frigate
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm install && npm run build
|
||||
@@ -18,7 +18,7 @@ RUN apt-get -qq update \
|
||||
gcc gfortran libopenblas-dev liblapack-dev cython
|
||||
|
||||
RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \
|
||||
&& python3 get-pip.py
|
||||
&& python3 get-pip.py "pip==20.2.4"
|
||||
|
||||
RUN pip3 install scikit-build
|
||||
|
||||
@@ -32,7 +32,9 @@ RUN pip3 wheel --wheel-dir=/wheels \
|
||||
paho-mqtt \
|
||||
PyYAML \
|
||||
matplotlib \
|
||||
click
|
||||
click \
|
||||
setproctitle \
|
||||
peewee
|
||||
|
||||
FROM scratch
|
||||
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
FROM ubuntu:20.04 as build
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get -qq update \
|
||||
&& apt-get -qq install -y \
|
||||
python3 \
|
||||
python3-dev \
|
||||
wget \
|
||||
# opencv dependencies
|
||||
build-essential cmake git pkg-config libgtk-3-dev \
|
||||
libavcodec-dev libavformat-dev libswscale-dev libv4l-dev \
|
||||
libxvidcore-dev libx264-dev libjpeg-dev libpng-dev libtiff-dev \
|
||||
gfortran openexr libatlas-base-dev libssl-dev\
|
||||
libtbb2 libtbb-dev libdc1394-22-dev libopenexr-dev \
|
||||
libgstreamer-plugins-base1.0-dev libgstreamer1.0-dev \
|
||||
# scipy dependencies
|
||||
gcc gfortran libopenblas-dev liblapack-dev cython
|
||||
|
||||
RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \
|
||||
&& python3 get-pip.py
|
||||
|
||||
# need to build cmake from source because binary distribution is broken for arm64
|
||||
# https://github.com/scikit-build/cmake-python-distributions/issues/115
|
||||
# https://github.com/skvark/opencv-python/issues/366
|
||||
# https://github.com/scikit-build/cmake-python-distributions/issues/96#issuecomment-663062358
|
||||
RUN pip3 install scikit-build
|
||||
|
||||
RUN git clone https://github.com/scikit-build/cmake-python-distributions.git \
|
||||
&& cd cmake-python-distributions/ \
|
||||
&& python3 setup.py bdist_wheel
|
||||
|
||||
RUN pip3 install cmake-python-distributions/dist/*.whl
|
||||
|
||||
RUN pip3 wheel --wheel-dir=/wheels \
|
||||
opencv-python-headless \
|
||||
numpy \
|
||||
imutils \
|
||||
scipy \
|
||||
psutil \
|
||||
Flask \
|
||||
paho-mqtt \
|
||||
PyYAML \
|
||||
matplotlib \
|
||||
click
|
||||
|
||||
FROM scratch
|
||||
|
||||
COPY --from=build /wheels /wheels
|
||||
@@ -5,7 +5,7 @@ Frigate should work with most RTSP cameras and h264 feeds such as Dahua.
|
||||
The input parameters need to be adjusted for RTMP cameras
|
||||
```yaml
|
||||
ffmpeg:
|
||||
input_args:
|
||||
input_args:
|
||||
- -avoid_negative_ts
|
||||
- make_zero
|
||||
- -fflags
|
||||
@@ -18,4 +18,25 @@ input_args:
|
||||
- +genpts+discardcorrupt
|
||||
- -use_wallclock_as_timestamps
|
||||
- '1'
|
||||
```
|
||||
```
|
||||
|
||||
## Blue Iris RTSP Cameras
|
||||
You will need to remove `nobuffer` flag for Blue Iris RTSP cameras
|
||||
```yaml
|
||||
ffmpeg:
|
||||
input_args:
|
||||
- -avoid_negative_ts
|
||||
- make_zero
|
||||
- -flags
|
||||
- low_delay
|
||||
- -strict
|
||||
- experimental
|
||||
- -fflags
|
||||
- +genpts+discardcorrupt
|
||||
- -rtsp_transport
|
||||
- tcp
|
||||
- -stimeout
|
||||
- "5000000"
|
||||
- -use_wallclock_as_timestamps
|
||||
- "1"
|
||||
```
|
||||
|
||||
@@ -32,7 +32,7 @@ automation:
|
||||
data_template:
|
||||
message: "A {{trigger.payload_json['after']['label']}} has left the yard."
|
||||
data:
|
||||
image: "https://url.com/api/frigate/notifications/{{trigger.payload_json['after']['id']}}.jpg"
|
||||
image: "https://url.com/api/frigate/notifications/{{trigger.payload_json['after']['id']}}/thumbnail.jpg"
|
||||
tag: "{{trigger.payload_json['after']['id']}}"
|
||||
|
||||
- alias: Notify for dogs in the front with a high top score
|
||||
@@ -48,7 +48,7 @@ automation:
|
||||
data_template:
|
||||
message: 'High confidence dog detection.'
|
||||
data:
|
||||
image: "https://url.com/api/frigate/notifications/{{trigger.payload_json['after']['id']}}.jpg"
|
||||
image: "https://url.com/api/frigate/notifications/{{trigger.payload_json['after']['id']}}/thumbnail.jpg"
|
||||
tag: "{{trigger.payload_json['after']['id']}}"
|
||||
```
|
||||
|
||||
@@ -66,6 +66,6 @@ automation:
|
||||
data:
|
||||
photo:
|
||||
# this url should work for addon users
|
||||
- url: 'http://ccab4aaf-frigate:5000/events/{{trigger.payload_json["after"]["id"]}}/snapshot.jpg'
|
||||
- url: 'http://ccab4aaf-frigate:5000/api/events/{{trigger.payload_json["after"]["id"]}}/thumbnail.jpg'
|
||||
caption : 'A {{trigger.payload_json["after"]["label"]}} was detected on {{ trigger.payload_json["after"]["camera"] }} camera'
|
||||
```
|
||||
|
||||
@@ -8,6 +8,7 @@ import sys
|
||||
import signal
|
||||
|
||||
import yaml
|
||||
from peewee_migrate import Router
|
||||
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
@@ -20,6 +21,7 @@ from frigate.models import Event
|
||||
from frigate.mqtt import create_mqtt_client
|
||||
from frigate.object_processing import TrackedObjectProcessor
|
||||
from frigate.record import RecordingMaintainer
|
||||
from frigate.stats import StatsEmitter, stats_init
|
||||
from frigate.video import capture_camera, track_camera
|
||||
from frigate.watchdog import FrigateWatchdog
|
||||
from frigate.zeroconf import broadcast_zeroconf
|
||||
@@ -37,20 +39,24 @@ class FrigateApp():
|
||||
self.log_queue = mp.Queue()
|
||||
self.camera_metrics = {}
|
||||
|
||||
def ensure_dirs(self):
|
||||
tmpfs_size = self.config.save_clips.tmpfs_cache_size
|
||||
if tmpfs_size:
|
||||
logger.info(f"Creating tmpfs of size {tmpfs_size}")
|
||||
rc = os.system(f"mount -t tmpfs -o size={tmpfs_size} tmpfs {CACHE_DIR}")
|
||||
if rc != 0:
|
||||
logger.error(f"Failed to create tmpfs, error code: {rc}")
|
||||
def set_environment_vars(self):
|
||||
for key, value in self.config.environment_vars.items():
|
||||
os.environ[key] = value
|
||||
|
||||
def ensure_dirs(self):
|
||||
for d in [RECORD_DIR, CLIPS_DIR, CACHE_DIR]:
|
||||
if not os.path.exists(d) and not os.path.islink(d):
|
||||
logger.info(f"Creating directory: {d}")
|
||||
os.makedirs(d)
|
||||
else:
|
||||
logger.debug(f"Skipping directory: {d}")
|
||||
|
||||
tmpfs_size = self.config.clips.tmpfs_cache_size
|
||||
if tmpfs_size:
|
||||
logger.info(f"Creating tmpfs of size {tmpfs_size}")
|
||||
rc = os.system(f"mount -t tmpfs -o size={tmpfs_size} tmpfs {CACHE_DIR}")
|
||||
if rc != 0:
|
||||
logger.error(f"Failed to create tmpfs, error code: {rc}")
|
||||
|
||||
def init_logger(self):
|
||||
self.log_process = mp.Process(target=log_process, args=(self.log_queue,), name='log_process')
|
||||
@@ -68,20 +74,21 @@ class FrigateApp():
|
||||
'camera_fps': mp.Value('d', 0.0),
|
||||
'skipped_fps': mp.Value('d', 0.0),
|
||||
'process_fps': mp.Value('d', 0.0),
|
||||
'detection_enabled': mp.Value('i', 1),
|
||||
'detection_fps': mp.Value('d', 0.0),
|
||||
'detection_frame': mp.Value('d', 0.0),
|
||||
'read_start': mp.Value('d', 0.0),
|
||||
'ffmpeg_pid': mp.Value('i', 0),
|
||||
'frame_queue': mp.Queue(maxsize=2)
|
||||
'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.save_clips.enabled and 'clips' in assigned_roles:
|
||||
logger.warning(f"Camera {name} has clips assigned to an input, but save_clips is not enabled.")
|
||||
elif camera.save_clips.enabled and not 'clips' in assigned_roles:
|
||||
logger.warning(f"Camera {name} has save_clips enabled, but clips is not assigned to an input.")
|
||||
if not camera.clips.enabled and 'clips' in assigned_roles:
|
||||
logger.warning(f"Camera {name} has clips assigned to an input, but clips is not enabled.")
|
||||
elif camera.clips.enabled and not 'clips' in assigned_roles:
|
||||
logger.warning(f"Camera {name} has clips enabled, but clips is not assigned to an input.")
|
||||
|
||||
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.")
|
||||
@@ -111,15 +118,23 @@ class FrigateApp():
|
||||
|
||||
def init_database(self):
|
||||
self.db = SqliteExtDatabase(self.config.database.path)
|
||||
|
||||
# Run migrations
|
||||
del(logging.getLogger('peewee_migrate').handlers[:])
|
||||
router = Router(self.db)
|
||||
router.run()
|
||||
|
||||
models = [Event]
|
||||
self.db.bind(models)
|
||||
self.db.create_tables(models, safe=True)
|
||||
|
||||
def init_stats(self):
|
||||
self.stats_tracking = stats_init(self.camera_metrics, self.detectors)
|
||||
|
||||
def init_web_server(self):
|
||||
self.flask_app = create_app(self.config, self.db, self.camera_metrics, self.detectors, self.detected_frames_processor)
|
||||
self.flask_app = create_app(self.config, self.db, self.stats_tracking, self.detected_frames_processor)
|
||||
|
||||
def init_mqtt(self):
|
||||
self.mqtt_client = create_mqtt_client(self.config.mqtt)
|
||||
self.mqtt_client = create_mqtt_client(self.config, self.camera_metrics)
|
||||
|
||||
def start_detectors(self):
|
||||
model_shape = (self.config.model.height, self.config.model.width)
|
||||
@@ -173,6 +188,10 @@ class FrigateApp():
|
||||
self.recording_maintainer = RecordingMaintainer(self.config, self.stop_event)
|
||||
self.recording_maintainer.start()
|
||||
|
||||
def start_stats_emitter(self):
|
||||
self.stats_emitter = StatsEmitter(self.config, self.stats_tracking, self.mqtt_client, self.config.mqtt.topic_prefix, self.stop_event)
|
||||
self.stats_emitter.start()
|
||||
|
||||
def start_watchdog(self):
|
||||
self.frigate_watchdog = FrigateWatchdog(self.detectors, self.stop_event)
|
||||
self.frigate_watchdog.start()
|
||||
@@ -186,6 +205,7 @@ class FrigateApp():
|
||||
logger.error(f"Error parsing config: {e}")
|
||||
self.log_process.terminate()
|
||||
sys.exit(1)
|
||||
self.set_environment_vars()
|
||||
self.ensure_dirs()
|
||||
self.check_config()
|
||||
self.set_log_levels()
|
||||
@@ -200,10 +220,12 @@ class FrigateApp():
|
||||
self.start_detected_frames_processor()
|
||||
self.start_camera_processors()
|
||||
self.start_camera_capture_processes()
|
||||
self.init_stats()
|
||||
self.init_web_server()
|
||||
self.start_event_processor()
|
||||
self.start_event_cleanup()
|
||||
self.start_recording_maintainer()
|
||||
self.start_stats_emitter()
|
||||
self.start_watchdog()
|
||||
# self.zeroconf = broadcast_zeroconf(self.config.mqtt.client_id)
|
||||
|
||||
@@ -224,6 +246,7 @@ class FrigateApp():
|
||||
self.event_processor.join()
|
||||
self.event_cleanup.join()
|
||||
self.recording_maintainer.join()
|
||||
self.stats_emitter.join()
|
||||
self.frigate_watchdog.join()
|
||||
|
||||
for detector in self.detectors.values():
|
||||
|
||||
@@ -8,6 +8,7 @@ import threading
|
||||
import signal
|
||||
from abc import ABC, abstractmethod
|
||||
from multiprocessing.connection import Connection
|
||||
from setproctitle import setproctitle
|
||||
from typing import Dict
|
||||
|
||||
import numpy as np
|
||||
@@ -61,16 +62,15 @@ class LocalObjectDetector(ObjectDetector):
|
||||
logger.info(f"Attempting to load TPU as {device_config['device']}")
|
||||
edge_tpu_delegate = load_delegate('libedgetpu.so.1.0', device_config)
|
||||
logger.info("TPU found")
|
||||
self.interpreter = tflite.Interpreter(
|
||||
model_path='/edgetpu_model.tflite',
|
||||
experimental_delegates=[edge_tpu_delegate])
|
||||
except ValueError:
|
||||
logger.info("No EdgeTPU detected. Falling back to CPU.")
|
||||
|
||||
if edge_tpu_delegate is None:
|
||||
self.interpreter = tflite.Interpreter(
|
||||
model_path='/cpu_model.tflite', num_threads=num_threads)
|
||||
logger.info("No EdgeTPU detected.")
|
||||
raise
|
||||
else:
|
||||
self.interpreter = tflite.Interpreter(
|
||||
model_path='/edgetpu_model.tflite',
|
||||
experimental_delegates=[edge_tpu_delegate])
|
||||
model_path='/cpu_model.tflite', num_threads=num_threads)
|
||||
|
||||
self.interpreter.allocate_tensors()
|
||||
|
||||
@@ -110,6 +110,7 @@ def run_detector(name: str, detection_queue: mp.Queue, out_events: Dict[str, mp.
|
||||
threading.current_thread().name = f"detector:{name}"
|
||||
logger = logging.getLogger(f"detector.{name}")
|
||||
logger.info(f"Starting detection process: {os.getpid()}")
|
||||
setproctitle(f"frigate.detector.{name}")
|
||||
listen()
|
||||
|
||||
stop_event = mp.Event()
|
||||
|
||||
@@ -88,7 +88,7 @@ class EventProcessor(threading.Thread):
|
||||
earliest_event = datetime.datetime.now().timestamp()
|
||||
|
||||
# if the earliest event exceeds the max seconds, cap it
|
||||
max_seconds = self.config.save_clips.max_seconds
|
||||
max_seconds = self.config.clips.max_seconds
|
||||
if datetime.datetime.now().timestamp()-earliest_event > max_seconds:
|
||||
earliest_event = datetime.datetime.now().timestamp()-max_seconds
|
||||
|
||||
@@ -102,6 +102,7 @@ class EventProcessor(threading.Thread):
|
||||
sorted_clips = sorted([c for c in self.cached_clips.values() if c['camera'] == camera], key = lambda i: i['start_time'])
|
||||
|
||||
while len(sorted_clips) == 0 or sorted_clips[-1]['start_time'] + sorted_clips[-1]['duration'] < event_data['end_time']+post_capture:
|
||||
logger.debug(f"No cache clips for {camera}. Waiting...")
|
||||
time.sleep(5)
|
||||
self.refresh_cache()
|
||||
# get all clips from the camera with the event sorted
|
||||
@@ -147,7 +148,8 @@ class EventProcessor(threading.Thread):
|
||||
p = sp.run(ffmpeg_cmd, input="\n".join(playlist_lines), encoding='ascii', capture_output=True)
|
||||
if p.returncode != 0:
|
||||
logger.error(p.stderr)
|
||||
return
|
||||
return False
|
||||
return True
|
||||
|
||||
def run(self):
|
||||
while True:
|
||||
@@ -162,28 +164,20 @@ class EventProcessor(threading.Thread):
|
||||
self.refresh_cache()
|
||||
continue
|
||||
|
||||
logger.debug(f"Event received: {event_type} {camera} {event_data['id']}")
|
||||
self.refresh_cache()
|
||||
|
||||
save_clips_config = self.config.cameras[camera].save_clips
|
||||
|
||||
# if save clips is not enabled for this camera, just continue
|
||||
if not save_clips_config.enabled:
|
||||
if event_type == 'end':
|
||||
self.event_processed_queue.put((event_data['id'], camera))
|
||||
continue
|
||||
|
||||
# if specific objects are listed for this camera, only save clips for them
|
||||
if not event_data['label'] in save_clips_config.objects:
|
||||
if event_type == 'end':
|
||||
self.event_processed_queue.put((event_data['id'], camera))
|
||||
continue
|
||||
|
||||
if event_type == 'start':
|
||||
self.events_in_process[event_data['id']] = event_data
|
||||
|
||||
if event_type == 'end':
|
||||
if len(self.cached_clips) > 0 and not event_data['false_positive']:
|
||||
self.create_clip(camera, event_data, save_clips_config.pre_capture, save_clips_config.post_capture)
|
||||
clips_config = self.config.cameras[camera].clips
|
||||
|
||||
if not event_data['false_positive']:
|
||||
clip_created = False
|
||||
if clips_config.enabled and (clips_config.objects is None or event_data['label'] in clips_config.objects):
|
||||
clip_created = self.create_clip(camera, event_data, clips_config.pre_capture, clips_config.post_capture)
|
||||
|
||||
Event.create(
|
||||
id=event_data['id'],
|
||||
label=event_data['label'],
|
||||
@@ -193,7 +187,9 @@ class EventProcessor(threading.Thread):
|
||||
top_score=event_data['top_score'],
|
||||
false_positive=event_data['false_positive'],
|
||||
zones=list(event_data['entered_zones']),
|
||||
thumbnail=event_data['thumbnail']
|
||||
thumbnail=event_data['thumbnail'],
|
||||
has_clip=clip_created,
|
||||
has_snapshot=event_data['has_snapshot'],
|
||||
)
|
||||
del self.events_in_process[event_data['id']]
|
||||
self.event_processed_queue.put((event_data['id'], camera))
|
||||
@@ -204,7 +200,86 @@ class EventCleanup(threading.Thread):
|
||||
self.name = 'event_cleanup'
|
||||
self.config = config
|
||||
self.stop_event = stop_event
|
||||
self.camera_keys = list(self.config.cameras.keys())
|
||||
|
||||
def expire(self, media):
|
||||
## Expire events from unlisted cameras based on the global config
|
||||
if media == 'clips':
|
||||
retain_config = self.config.clips.retain
|
||||
file_extension = 'mp4'
|
||||
update_params = {'has_clip': False}
|
||||
else:
|
||||
retain_config = self.config.snapshots.retain
|
||||
file_extension = 'jpg'
|
||||
update_params = {'has_snapshot': False}
|
||||
|
||||
distinct_labels = (Event.select(Event.label)
|
||||
.where(Event.camera.not_in(self.camera_keys))
|
||||
.distinct())
|
||||
|
||||
# loop over object types in db
|
||||
for l in distinct_labels:
|
||||
# get expiration time for this label
|
||||
expire_days = retain_config.objects.get(l.label, retain_config.default)
|
||||
expire_after = (datetime.datetime.now() - datetime.timedelta(days=expire_days)).timestamp()
|
||||
# grab all events after specific time
|
||||
expired_events = (
|
||||
Event.select()
|
||||
.where(Event.camera.not_in(self.camera_keys),
|
||||
Event.start_time < expire_after,
|
||||
Event.label == l.label)
|
||||
)
|
||||
# delete the media from disk
|
||||
for event in expired_events:
|
||||
media_name = f"{event.camera}-{event.id}"
|
||||
media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.{file_extension}")
|
||||
media.unlink(missing_ok=True)
|
||||
# update the clips attribute for the db entry
|
||||
update_query = (
|
||||
Event.update(update_params)
|
||||
.where(Event.camera.not_in(self.camera_keys),
|
||||
Event.start_time < expire_after,
|
||||
Event.label == l.label)
|
||||
)
|
||||
update_query.execute()
|
||||
|
||||
## Expire events from cameras based on the camera config
|
||||
for name, camera in self.config.cameras.items():
|
||||
if media == 'clips':
|
||||
retain_config = camera.clips.retain
|
||||
else:
|
||||
retain_config = camera.snapshots.retain
|
||||
# get distinct objects in database for this camera
|
||||
distinct_labels = (Event.select(Event.label)
|
||||
.where(Event.camera == name)
|
||||
.distinct())
|
||||
|
||||
# loop over object types in db
|
||||
for l in distinct_labels:
|
||||
# get expiration time for this label
|
||||
expire_days = retain_config.objects.get(l.label, retain_config.default)
|
||||
expire_after = (datetime.datetime.now() - datetime.timedelta(days=expire_days)).timestamp()
|
||||
# grab all events after specific time
|
||||
expired_events = (
|
||||
Event.select()
|
||||
.where(Event.camera == name,
|
||||
Event.start_time < expire_after,
|
||||
Event.label == l.label)
|
||||
)
|
||||
# delete the grabbed clips from disk
|
||||
for event in expired_events:
|
||||
media_name = f"{event.camera}-{event.id}"
|
||||
media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.{file_extension}")
|
||||
media.unlink(missing_ok=True)
|
||||
# update the clips attribute for the db entry
|
||||
update_query = (
|
||||
Event.update(update_params)
|
||||
.where( Event.camera == name,
|
||||
Event.start_time < expire_after,
|
||||
Event.label == l.label)
|
||||
)
|
||||
update_query.execute()
|
||||
|
||||
def run(self):
|
||||
counter = 0
|
||||
while(True):
|
||||
@@ -219,71 +294,13 @@ class EventCleanup(threading.Thread):
|
||||
continue
|
||||
counter = 0
|
||||
|
||||
camera_keys = list(self.config.cameras.keys())
|
||||
self.expire('clips')
|
||||
self.expire('snapshots')
|
||||
|
||||
# Expire events from unlisted cameras based on the global config
|
||||
retain_config = self.config.save_clips.retain
|
||||
|
||||
distinct_labels = (Event.select(Event.label)
|
||||
.where(Event.camera.not_in(camera_keys))
|
||||
.distinct())
|
||||
|
||||
# loop over object types in db
|
||||
for l in distinct_labels:
|
||||
# get expiration time for this label
|
||||
expire_days = retain_config.objects.get(l.label, retain_config.default)
|
||||
expire_after = (datetime.datetime.now() - datetime.timedelta(days=expire_days)).timestamp()
|
||||
# grab all events after specific time
|
||||
expired_events = (
|
||||
Event.select()
|
||||
.where(Event.camera.not_in(camera_keys),
|
||||
Event.start_time < expire_after,
|
||||
Event.label == l.label)
|
||||
)
|
||||
# delete the grabbed clips from disk
|
||||
for event in expired_events:
|
||||
clip_name = f"{event.camera}-{event.id}"
|
||||
clip = Path(f"{os.path.join(CLIPS_DIR, clip_name)}.mp4")
|
||||
clip.unlink(missing_ok=True)
|
||||
# delete the event for this type from the db
|
||||
delete_query = (
|
||||
Event.delete()
|
||||
.where(Event.camera.not_in(camera_keys),
|
||||
Event.start_time < expire_after,
|
||||
Event.label == l.label)
|
||||
)
|
||||
delete_query.execute()
|
||||
|
||||
# Expire events from cameras based on the camera config
|
||||
for name, camera in self.config.cameras.items():
|
||||
retain_config = camera.save_clips.retain
|
||||
# get distinct objects in database for this camera
|
||||
distinct_labels = (Event.select(Event.label)
|
||||
.where(Event.camera == name)
|
||||
.distinct())
|
||||
|
||||
# loop over object types in db
|
||||
for l in distinct_labels:
|
||||
# get expiration time for this label
|
||||
expire_days = retain_config.objects.get(l.label, retain_config.default)
|
||||
expire_after = (datetime.datetime.now() - datetime.timedelta(days=expire_days)).timestamp()
|
||||
# grab all events after specific time
|
||||
expired_events = (
|
||||
Event.select()
|
||||
.where(Event.camera == name,
|
||||
Event.start_time < expire_after,
|
||||
Event.label == l.label)
|
||||
)
|
||||
# delete the grabbed clips from disk
|
||||
for event in expired_events:
|
||||
clip_name = f"{event.camera}-{event.id}"
|
||||
clip = Path(f"{os.path.join(CLIPS_DIR, clip_name)}.mp4")
|
||||
clip.unlink(missing_ok=True)
|
||||
# delete the event for this type from the db
|
||||
delete_query = (
|
||||
Event.delete()
|
||||
.where( Event.camera == name,
|
||||
Event.start_time < expire_after,
|
||||
Event.label == l.label)
|
||||
)
|
||||
delete_query.execute()
|
||||
# drop events from db where has_clip and has_snapshot are false
|
||||
delete_query = (
|
||||
Event.delete()
|
||||
.where( Event.has_clip == False,
|
||||
Event.has_snapshot == False)
|
||||
)
|
||||
delete_query.execute()
|
||||
|
||||
@@ -13,6 +13,7 @@ from peewee import SqliteDatabase, operator, fn, DoesNotExist
|
||||
from playhouse.shortcuts import model_to_dict
|
||||
|
||||
from frigate.models import Event
|
||||
from frigate.stats import stats_snapshot
|
||||
from frigate.util import calculate_region
|
||||
from frigate.version import VERSION
|
||||
|
||||
@@ -20,7 +21,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('frigate', __name__)
|
||||
|
||||
def create_app(frigate_config, database: SqliteDatabase, camera_metrics, detectors, detected_frames_processor):
|
||||
def create_app(frigate_config, database: SqliteDatabase, stats_tracking, detected_frames_processor):
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.before_request
|
||||
@@ -33,10 +34,9 @@ def create_app(frigate_config, database: SqliteDatabase, camera_metrics, detecto
|
||||
database.close()
|
||||
|
||||
app.frigate_config = frigate_config
|
||||
app.camera_metrics = camera_metrics
|
||||
app.detectors = detectors
|
||||
app.stats_tracking = stats_tracking
|
||||
app.detected_frames_processor = detected_frames_processor
|
||||
|
||||
|
||||
app.register_blueprint(bp)
|
||||
|
||||
return app
|
||||
@@ -47,18 +47,33 @@ def is_healthy():
|
||||
|
||||
@bp.route('/events/summary')
|
||||
def events_summary():
|
||||
has_clip = request.args.get('has_clip', type=int)
|
||||
has_snapshot = request.args.get('has_snapshot', type=int)
|
||||
|
||||
clauses = []
|
||||
|
||||
if not has_clip is None:
|
||||
clauses.append((Event.has_clip == has_clip))
|
||||
|
||||
if not has_snapshot is None:
|
||||
clauses.append((Event.has_snapshot == has_snapshot))
|
||||
|
||||
if len(clauses) == 0:
|
||||
clauses.append((1 == 1))
|
||||
|
||||
groups = (
|
||||
Event
|
||||
.select(
|
||||
Event.camera,
|
||||
Event.label,
|
||||
fn.strftime('%Y-%m-%d', fn.datetime(Event.start_time, 'unixepoch', 'localtime')).alias('day'),
|
||||
Event.camera,
|
||||
Event.label,
|
||||
fn.strftime('%Y-%m-%d', fn.datetime(Event.start_time, 'unixepoch', 'localtime')).alias('day'),
|
||||
Event.zones,
|
||||
fn.COUNT(Event.id).alias('count')
|
||||
)
|
||||
.where(reduce(operator.and_, clauses))
|
||||
.group_by(
|
||||
Event.camera,
|
||||
Event.label,
|
||||
Event.camera,
|
||||
Event.label,
|
||||
fn.strftime('%Y-%m-%d', fn.datetime(Event.start_time, 'unixepoch', 'localtime')),
|
||||
Event.zones
|
||||
)
|
||||
@@ -73,7 +88,7 @@ def event(id):
|
||||
except DoesNotExist:
|
||||
return "Event not found", 404
|
||||
|
||||
@bp.route('/events/<id>/snapshot.jpg')
|
||||
@bp.route('/events/<id>/thumbnail.jpg')
|
||||
def event_snapshot(id):
|
||||
format = request.args.get('format', 'ios')
|
||||
thumbnail_bytes = None
|
||||
@@ -90,18 +105,18 @@ def event_snapshot(id):
|
||||
thumbnail_bytes = tracked_obj.get_jpg_bytes()
|
||||
except:
|
||||
return "Event not found", 404
|
||||
|
||||
|
||||
if thumbnail_bytes is None:
|
||||
return "Event not found", 404
|
||||
|
||||
|
||||
# android notifications prefer a 2:1 ratio
|
||||
if format == 'android':
|
||||
jpg_as_np = np.frombuffer(thumbnail_bytes, dtype=np.uint8)
|
||||
img = cv2.imdecode(jpg_as_np, flags=1)
|
||||
thumbnail = cv2.copyMakeBorder(img, 0, 0, int(img.shape[1]*0.5), int(img.shape[1]*0.5), cv2.BORDER_CONSTANT, (0,0,0))
|
||||
ret, jpg = cv2.imencode('.jpg', thumbnail)
|
||||
ret, jpg = cv2.imencode('.jpg', thumbnail)
|
||||
thumbnail_bytes = jpg.tobytes()
|
||||
|
||||
|
||||
response = make_response(thumbnail_bytes)
|
||||
response.headers['Content-Type'] = 'image/jpg'
|
||||
return response
|
||||
@@ -114,24 +129,32 @@ def events():
|
||||
zone = request.args.get('zone')
|
||||
after = request.args.get('after', type=int)
|
||||
before = request.args.get('before', type=int)
|
||||
has_clip = request.args.get('has_clip', type=int)
|
||||
has_snapshot = request.args.get('has_snapshot', type=int)
|
||||
|
||||
clauses = []
|
||||
|
||||
|
||||
if camera:
|
||||
clauses.append((Event.camera == camera))
|
||||
|
||||
|
||||
if label:
|
||||
clauses.append((Event.label == label))
|
||||
|
||||
|
||||
if zone:
|
||||
clauses.append((Event.zones.cast('text') % f"*\"{zone}\"*"))
|
||||
|
||||
|
||||
if after:
|
||||
clauses.append((Event.start_time >= after))
|
||||
|
||||
|
||||
if before:
|
||||
clauses.append((Event.start_time <= before))
|
||||
|
||||
if not has_clip is None:
|
||||
clauses.append((Event.has_clip == has_clip))
|
||||
|
||||
if not has_snapshot is None:
|
||||
clauses.append((Event.has_snapshot == has_snapshot))
|
||||
|
||||
if len(clauses) == 0:
|
||||
clauses.append((1 == 1))
|
||||
|
||||
@@ -152,31 +175,7 @@ def version():
|
||||
|
||||
@bp.route('/stats')
|
||||
def stats():
|
||||
camera_metrics = current_app.camera_metrics
|
||||
stats = {}
|
||||
|
||||
total_detection_fps = 0
|
||||
|
||||
for name, camera_stats in camera_metrics.items():
|
||||
total_detection_fps += camera_stats['detection_fps'].value
|
||||
stats[name] = {
|
||||
'camera_fps': round(camera_stats['camera_fps'].value, 2),
|
||||
'process_fps': round(camera_stats['process_fps'].value, 2),
|
||||
'skipped_fps': round(camera_stats['skipped_fps'].value, 2),
|
||||
'detection_fps': round(camera_stats['detection_fps'].value, 2),
|
||||
'pid': camera_stats['process'].pid,
|
||||
'capture_pid': camera_stats['capture_process'].pid
|
||||
}
|
||||
|
||||
stats['detectors'] = {}
|
||||
for name, detector in current_app.detectors.items():
|
||||
stats['detectors'][name] = {
|
||||
'inference_speed': round(detector.avg_inference_speed.value*1000, 2),
|
||||
'detection_start': detector.detection_start.value,
|
||||
'pid': detector.detect_process.pid
|
||||
}
|
||||
stats['detection_fps'] = round(total_detection_fps, 2)
|
||||
|
||||
stats = stats_snapshot(current_app.stats_tracking)
|
||||
return jsonify(stats)
|
||||
|
||||
@bp.route('/<camera_name>/<label>/best.jpg')
|
||||
@@ -188,13 +187,13 @@ def best(camera_name, label):
|
||||
best_frame = np.zeros((720,1280,3), np.uint8)
|
||||
else:
|
||||
best_frame = cv2.cvtColor(best_frame, cv2.COLOR_YUV2BGR_I420)
|
||||
|
||||
|
||||
crop = bool(request.args.get('crop', 0, type=int))
|
||||
if crop:
|
||||
box = best_object.get('box', (0,0,300,300))
|
||||
region = calculate_region(best_frame.shape, box[0], box[1], box[2], box[3], 1.1)
|
||||
best_frame = best_frame[region[1]:region[3], region[0]:region[2]]
|
||||
|
||||
|
||||
height = int(request.args.get('h', str(best_frame.shape[0])))
|
||||
width = int(height*best_frame.shape[1]/best_frame.shape[0])
|
||||
|
||||
@@ -252,7 +251,7 @@ def latest_frame(camera_name):
|
||||
return response
|
||||
else:
|
||||
return "Camera named {} not found".format(camera_name), 404
|
||||
|
||||
|
||||
def imagestream(detected_frames_processor, camera_name, fps, height, draw_options):
|
||||
while True:
|
||||
# max out at specified FPS
|
||||
|
||||
@@ -6,6 +6,7 @@ import signal
|
||||
import queue
|
||||
import multiprocessing as mp
|
||||
from logging import handlers
|
||||
from setproctitle import setproctitle
|
||||
|
||||
|
||||
def listener_configurer():
|
||||
@@ -31,6 +32,7 @@ def log_process(log_queue):
|
||||
signal.signal(signal.SIGINT, receiveSignal)
|
||||
|
||||
threading.current_thread().name = f"logger"
|
||||
setproctitle("frigate.logger")
|
||||
listener_configurer()
|
||||
while True:
|
||||
if stop_event.is_set() and log_queue.empty():
|
||||
@@ -72,4 +74,4 @@ class LogPipe(threading.Thread):
|
||||
def close(self):
|
||||
"""Close the write end of the pipe.
|
||||
"""
|
||||
os.close(self.fdWrite)
|
||||
os.close(self.fdWrite)
|
||||
|
||||
@@ -12,3 +12,5 @@ class Event(Model):
|
||||
false_positive = BooleanField()
|
||||
zones = JSONField()
|
||||
thumbnail = TextField()
|
||||
has_clip = BooleanField(default=True)
|
||||
has_snapshot = BooleanField(default=True)
|
||||
|
||||
@@ -5,7 +5,7 @@ from frigate.config import MotionConfig
|
||||
|
||||
|
||||
class MotionDetector():
|
||||
def __init__(self, frame_shape, mask, config: MotionConfig):
|
||||
def __init__(self, frame_shape, config: MotionConfig):
|
||||
self.config = config
|
||||
self.frame_shape = frame_shape
|
||||
self.resize_factor = frame_shape[0]/config.frame_height
|
||||
@@ -14,7 +14,7 @@ class MotionDetector():
|
||||
self.avg_delta = np.zeros(self.motion_frame_size, np.float)
|
||||
self.motion_frame_count = 0
|
||||
self.frame_counter = 0
|
||||
resized_mask = cv2.resize(mask, dsize=(self.motion_frame_size[1], self.motion_frame_size[0]), interpolation=cv2.INTER_LINEAR)
|
||||
resized_mask = cv2.resize(config.mask, dsize=(self.motion_frame_size[1], self.motion_frame_size[0]), interpolation=cv2.INTER_LINEAR)
|
||||
self.mask = np.where(resized_mask==[0])
|
||||
|
||||
def detect(self, frame):
|
||||
|
||||
111
frigate/mqtt.py
@@ -3,12 +3,87 @@ import threading
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
from frigate.config import MqttConfig
|
||||
from frigate.config import FrigateConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def create_mqtt_client(config: MqttConfig):
|
||||
client = mqtt.Client(client_id=config.client_id)
|
||||
def create_mqtt_client(config: FrigateConfig, camera_metrics):
|
||||
mqtt_config = config.mqtt
|
||||
|
||||
def on_clips_command(client, userdata, message):
|
||||
payload = message.payload.decode()
|
||||
logger.debug(f"on_clips_toggle: {message.topic} {payload}")
|
||||
|
||||
camera_name = message.topic.split('/')[-3]
|
||||
command = message.topic.split('/')[-1]
|
||||
|
||||
clips_settings = config.cameras[camera_name].clips
|
||||
|
||||
if payload == 'ON':
|
||||
if not clips_settings.enabled:
|
||||
logger.info(f"Turning on clips for {camera_name} via mqtt")
|
||||
clips_settings._enabled = True
|
||||
elif payload == 'OFF':
|
||||
if clips_settings.enabled:
|
||||
logger.info(f"Turning off clips for {camera_name} via mqtt")
|
||||
clips_settings._enabled = False
|
||||
else:
|
||||
logger.warning(f"Received unsupported value at {message.topic}: {payload}")
|
||||
|
||||
if command == "set":
|
||||
state_topic = f"{message.topic[:-4]}/state"
|
||||
client.publish(state_topic, payload, retain=True)
|
||||
|
||||
def on_snapshots_command(client, userdata, message):
|
||||
payload = message.payload.decode()
|
||||
logger.debug(f"on_snapshots_toggle: {message.topic} {payload}")
|
||||
|
||||
camera_name = message.topic.split('/')[-3]
|
||||
command = message.topic.split('/')[-1]
|
||||
|
||||
snapshots_settings = config.cameras[camera_name].snapshots
|
||||
|
||||
if payload == 'ON':
|
||||
if not snapshots_settings.enabled:
|
||||
logger.info(f"Turning on snapshots for {camera_name} via mqtt")
|
||||
snapshots_settings._enabled = True
|
||||
elif payload == 'OFF':
|
||||
if snapshots_settings.enabled:
|
||||
logger.info(f"Turning off snapshots for {camera_name} via mqtt")
|
||||
snapshots_settings._enabled = False
|
||||
else:
|
||||
logger.warning(f"Received unsupported value at {message.topic}: {payload}")
|
||||
|
||||
if command == "set":
|
||||
state_topic = f"{message.topic[:-4]}/state"
|
||||
client.publish(state_topic, payload, retain=True)
|
||||
|
||||
def on_detect_command(client, userdata, message):
|
||||
payload = message.payload.decode()
|
||||
logger.debug(f"on_detect_toggle: {message.topic} {payload}")
|
||||
|
||||
camera_name = message.topic.split('/')[-3]
|
||||
command = message.topic.split('/')[-1]
|
||||
|
||||
detect_settings = config.cameras[camera_name].detect
|
||||
|
||||
if payload == 'ON':
|
||||
if not camera_metrics[camera_name]["detection_enabled"].value:
|
||||
logger.info(f"Turning on detection for {camera_name} via mqtt")
|
||||
camera_metrics[camera_name]["detection_enabled"].value = True
|
||||
detect_settings._enabled = True
|
||||
elif payload == 'OFF':
|
||||
if camera_metrics[camera_name]["detection_enabled"].value:
|
||||
logger.info(f"Turning off detection for {camera_name} via mqtt")
|
||||
camera_metrics[camera_name]["detection_enabled"].value = False
|
||||
detect_settings._enabled = False
|
||||
else:
|
||||
logger.warning(f"Received unsupported value at {message.topic}: {payload}")
|
||||
|
||||
if command == "set":
|
||||
state_topic = f"{message.topic[:-4]}/state"
|
||||
client.publish(state_topic, payload, retain=True)
|
||||
|
||||
def on_connect(client, userdata, flags, rc):
|
||||
threading.current_thread().name = "mqtt"
|
||||
if rc != 0:
|
||||
@@ -22,15 +97,35 @@ def create_mqtt_client(config: MqttConfig):
|
||||
logger.error("Unable to connect to MQTT: Connection refused. Error code: " + str(rc))
|
||||
|
||||
logger.info("MQTT connected")
|
||||
client.publish(config.topic_prefix+'/available', 'online', retain=True)
|
||||
client.publish(mqtt_config.topic_prefix+'/available', 'online', retain=True)
|
||||
|
||||
client = mqtt.Client(client_id=mqtt_config.client_id)
|
||||
client.on_connect = on_connect
|
||||
client.will_set(config.topic_prefix+'/available', payload='offline', qos=1, retain=True)
|
||||
if not config.user is None:
|
||||
client.username_pw_set(config.user, password=config.password)
|
||||
client.will_set(mqtt_config.topic_prefix+'/available', payload='offline', qos=1, retain=True)
|
||||
|
||||
# register callbacks
|
||||
for name in config.cameras.keys():
|
||||
client.message_callback_add(f"{mqtt_config.topic_prefix}/{name}/clips/#", on_clips_command)
|
||||
client.message_callback_add(f"{mqtt_config.topic_prefix}/{name}/snapshots/#", on_snapshots_command)
|
||||
client.message_callback_add(f"{mqtt_config.topic_prefix}/{name}/detect/#", on_detect_command)
|
||||
|
||||
if not mqtt_config.user is None:
|
||||
client.username_pw_set(mqtt_config.user, password=mqtt_config.password)
|
||||
try:
|
||||
client.connect(config.host, config.port, 60)
|
||||
client.connect(mqtt_config.host, mqtt_config.port, 60)
|
||||
except Exception as e:
|
||||
logger.error(f"Unable to connect to MQTT server: {e}")
|
||||
raise
|
||||
|
||||
client.loop_start()
|
||||
|
||||
for name in config.cameras.keys():
|
||||
client.publish(f"{mqtt_config.topic_prefix}/{name}/clips/state", 'ON' if config.cameras[name].clips.enabled else 'OFF', retain=True)
|
||||
client.publish(f"{mqtt_config.topic_prefix}/{name}/snapshots/state", 'ON' if config.cameras[name].clips.enabled else 'OFF', retain=True)
|
||||
client.publish(f"{mqtt_config.topic_prefix}/{name}/detect/state", 'ON' if config.cameras[name].clips.enabled else 'OFF', retain=True)
|
||||
|
||||
client.subscribe(f"{mqtt_config.topic_prefix}/+/clips/#")
|
||||
client.subscribe(f"{mqtt_config.topic_prefix}/+/snapshots/#")
|
||||
client.subscribe(f"{mqtt_config.topic_prefix}/+/detect/#")
|
||||
|
||||
return client
|
||||
|
||||
@@ -54,11 +54,11 @@ def is_better_thumbnail(current_thumb, new_obj, frame_shape) -> bool:
|
||||
# if the score is better by more than 5%
|
||||
if new_obj['score'] > current_thumb['score']+.05:
|
||||
return True
|
||||
|
||||
|
||||
# if the area is 10% larger
|
||||
if new_obj['area'] > current_thumb['area']*1.1:
|
||||
return True
|
||||
|
||||
|
||||
return False
|
||||
|
||||
class TrackedObject():
|
||||
@@ -74,9 +74,6 @@ class TrackedObject():
|
||||
self.thumbnail_data = None
|
||||
self.frame = None
|
||||
self.previous = self.to_dict()
|
||||
self._snapshot_jpg_time = 0
|
||||
ret, jpg = cv2.imencode('.jpg', np.zeros((300,300,3), np.uint8))
|
||||
self._snapshot_jpg = jpg.tobytes()
|
||||
|
||||
# start the score history
|
||||
self.score_history = [self.obj_data['score']]
|
||||
@@ -97,7 +94,7 @@ class TrackedObject():
|
||||
if len(scores) < 3:
|
||||
scores += [0.0]*(3 - len(scores))
|
||||
return median(scores)
|
||||
|
||||
|
||||
def update(self, current_frame_time, obj_data):
|
||||
significant_update = False
|
||||
self.obj_data.update(obj_data)
|
||||
@@ -119,7 +116,7 @@ class TrackedObject():
|
||||
if not self.false_positive:
|
||||
# determine if this frame is a better thumbnail
|
||||
if (
|
||||
self.thumbnail_data is None
|
||||
self.thumbnail_data is None
|
||||
or is_better_thumbnail(self.thumbnail_data, self.obj_data, self.camera_config.frame_shape)
|
||||
):
|
||||
self.thumbnail_data = {
|
||||
@@ -130,7 +127,7 @@ class TrackedObject():
|
||||
'score': self.obj_data['score']
|
||||
}
|
||||
significant_update = True
|
||||
|
||||
|
||||
# check zones
|
||||
current_zones = []
|
||||
bottom_center = (self.obj_data['centroid'][0], self.obj_data['box'][3])
|
||||
@@ -143,14 +140,14 @@ class TrackedObject():
|
||||
if name in self.current_zones or not zone_filtered(self, zone.filters):
|
||||
current_zones.append(name)
|
||||
self.entered_zones.add(name)
|
||||
|
||||
|
||||
# if the zones changed, signal an update
|
||||
if not self.false_positive and set(self.current_zones) != set(current_zones):
|
||||
significant_update = True
|
||||
|
||||
self.current_zones = current_zones
|
||||
return significant_update
|
||||
|
||||
|
||||
def to_dict(self, include_thumbnail: bool = False):
|
||||
return {
|
||||
'id': self.obj_data['id'],
|
||||
@@ -167,54 +164,58 @@ class TrackedObject():
|
||||
'region': self.obj_data['region'],
|
||||
'current_zones': self.current_zones.copy(),
|
||||
'entered_zones': list(self.entered_zones).copy(),
|
||||
'thumbnail': base64.b64encode(self.get_jpg_bytes()).decode('utf-8') if include_thumbnail else None
|
||||
'thumbnail': base64.b64encode(self.get_thumbnail()).decode('utf-8') if include_thumbnail else None
|
||||
}
|
||||
|
||||
def get_thumbnail(self):
|
||||
if self.thumbnail_data is None or not self.thumbnail_data['frame_time'] in self.frame_cache:
|
||||
ret, jpg = cv2.imencode('.jpg', np.zeros((175,175,3), np.uint8))
|
||||
|
||||
jpg_bytes = self.get_jpg_bytes(timestamp=False, bounding_box=False, crop=True, height=175)
|
||||
|
||||
if jpg_bytes:
|
||||
return jpg_bytes
|
||||
else:
|
||||
ret, jpg = cv2.imencode('.jpg', np.zeros((175,175,3), np.uint8))
|
||||
return jpg.tobytes()
|
||||
|
||||
def get_jpg_bytes(self):
|
||||
if self.thumbnail_data is None or self._snapshot_jpg_time == self.thumbnail_data['frame_time']:
|
||||
return self._snapshot_jpg
|
||||
|
||||
if not self.thumbnail_data['frame_time'] in self.frame_cache:
|
||||
logger.error(f"Unable to create thumbnail for {self.obj_data['id']}")
|
||||
logger.error(f"Looking for frame_time of {self.thumbnail_data['frame_time']}")
|
||||
logger.error(f"Thumbnail frames: {','.join([str(k) for k in self.frame_cache.keys()])}")
|
||||
return self._snapshot_jpg
|
||||
|
||||
# TODO: crop first to avoid converting the entire frame?
|
||||
snapshot_config = self.camera_config.snapshots
|
||||
def get_jpg_bytes(self, timestamp=False, bounding_box=False, crop=False, height=None):
|
||||
if self.thumbnail_data is None:
|
||||
return None
|
||||
|
||||
best_frame = cv2.cvtColor(self.frame_cache[self.thumbnail_data['frame_time']], cv2.COLOR_YUV2BGR_I420)
|
||||
|
||||
if snapshot_config.draw_bounding_boxes:
|
||||
|
||||
if bounding_box:
|
||||
thickness = 2
|
||||
color = COLOR_MAP[self.obj_data['label']]
|
||||
|
||||
# draw the bounding boxes on the frame
|
||||
box = self.thumbnail_data['box']
|
||||
draw_box_with_label(best_frame, box[0], box[1], box[2], box[3], self.obj_data['label'],
|
||||
f"{int(self.thumbnail_data['score']*100)}% {int(self.thumbnail_data['area'])}", thickness=thickness, color=color)
|
||||
|
||||
if snapshot_config.crop_to_region:
|
||||
draw_box_with_label(best_frame, box[0], box[1], box[2], box[3], self.obj_data['label'], f"{int(self.thumbnail_data['score']*100)}% {int(self.thumbnail_data['area'])}", thickness=thickness, color=color)
|
||||
|
||||
if crop:
|
||||
box = self.thumbnail_data['box']
|
||||
region = calculate_region(best_frame.shape, box[0], box[1], box[2], box[3], 1.1)
|
||||
best_frame = best_frame[region[1]:region[3], region[0]:region[2]]
|
||||
|
||||
if snapshot_config.height:
|
||||
height = snapshot_config.height
|
||||
if height:
|
||||
width = int(height*best_frame.shape[1]/best_frame.shape[0])
|
||||
best_frame = cv2.resize(best_frame, dsize=(width, height), interpolation=cv2.INTER_AREA)
|
||||
|
||||
if snapshot_config.show_timestamp:
|
||||
|
||||
if timestamp:
|
||||
time_to_show = datetime.datetime.fromtimestamp(self.thumbnail_data['frame_time']).strftime("%m/%d/%Y %H:%M:%S")
|
||||
size = cv2.getTextSize(time_to_show, cv2.FONT_HERSHEY_SIMPLEX, fontScale=1, thickness=2)
|
||||
text_width = size[0][0]
|
||||
desired_size = max(150, 0.33*best_frame.shape[1])
|
||||
font_scale = desired_size/text_width
|
||||
cv2.putText(best_frame, time_to_show, (5, best_frame.shape[0]-7), cv2.FONT_HERSHEY_SIMPLEX,
|
||||
cv2.putText(best_frame, time_to_show, (5, best_frame.shape[0]-7), cv2.FONT_HERSHEY_SIMPLEX,
|
||||
fontScale=font_scale, color=(255, 255, 255), thickness=2)
|
||||
|
||||
ret, jpg = cv2.imencode('.jpg', best_frame)
|
||||
if ret:
|
||||
self._snapshot_jpg = jpg.tobytes()
|
||||
|
||||
return self._snapshot_jpg
|
||||
return jpg.tobytes()
|
||||
else:
|
||||
return None
|
||||
|
||||
def zone_filtered(obj: TrackedObject, object_config):
|
||||
object_name = obj.obj_data['label']
|
||||
@@ -226,7 +227,7 @@ def zone_filtered(obj: TrackedObject, object_config):
|
||||
# detected object, don't add it to detected objects
|
||||
if obj_settings.min_area > obj.obj_data['area']:
|
||||
return True
|
||||
|
||||
|
||||
# if the detected object is larger than the
|
||||
# max area, don't add it to detected objects
|
||||
if obj_settings.max_area < obj.obj_data['area']:
|
||||
@@ -235,7 +236,7 @@ def zone_filtered(obj: TrackedObject, object_config):
|
||||
# if the score is lower than the threshold, skip
|
||||
if obj_settings.threshold > obj.computed_score:
|
||||
return True
|
||||
|
||||
|
||||
return False
|
||||
|
||||
# Maintains the state of a camera
|
||||
@@ -253,6 +254,8 @@ class CameraState():
|
||||
self._current_frame = np.zeros(self.camera_config.frame_shape_yuv, np.uint8)
|
||||
self.current_frame_lock = threading.Lock()
|
||||
self.current_frame_time = 0.0
|
||||
self.motion_boxes = []
|
||||
self.regions = []
|
||||
self.previous_frame_id = None
|
||||
self.callbacks = defaultdict(lambda: [])
|
||||
|
||||
@@ -263,7 +266,7 @@ class CameraState():
|
||||
tracked_objects = {k: v.to_dict() for k,v in self.tracked_objects.items()}
|
||||
motion_boxes = self.motion_boxes.copy()
|
||||
regions = self.regions.copy()
|
||||
|
||||
|
||||
frame_copy = cv2.cvtColor(frame_copy, cv2.COLOR_YUV2BGR_I420)
|
||||
# draw on the frame
|
||||
if draw_options.get('bounding_boxes'):
|
||||
@@ -271,7 +274,7 @@ class CameraState():
|
||||
for obj in tracked_objects.values():
|
||||
thickness = 2
|
||||
color = COLOR_MAP[obj['label']]
|
||||
|
||||
|
||||
if obj['frame_time'] != frame_time:
|
||||
thickness = 1
|
||||
color = (255,0,0)
|
||||
@@ -279,28 +282,28 @@ class CameraState():
|
||||
# draw the bounding boxes on the frame
|
||||
box = obj['box']
|
||||
draw_box_with_label(frame_copy, box[0], box[1], box[2], box[3], obj['label'], f"{int(obj['score']*100)}% {int(obj['area'])}", thickness=thickness, color=color)
|
||||
|
||||
|
||||
if draw_options.get('regions'):
|
||||
for region in regions:
|
||||
cv2.rectangle(frame_copy, (region[0], region[1]), (region[2], region[3]), (0,255,0), 2)
|
||||
|
||||
if draw_options.get('timestamp'):
|
||||
time_to_show = datetime.datetime.fromtimestamp(frame_time).strftime("%m/%d/%Y %H:%M:%S")
|
||||
cv2.putText(frame_copy, time_to_show, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, fontScale=.8, color=(255, 255, 255), thickness=2)
|
||||
|
||||
if draw_options.get('zones'):
|
||||
for name, zone in self.camera_config.zones.items():
|
||||
thickness = 8 if any([name in obj['current_zones'] for obj in tracked_objects.values()]) else 2
|
||||
cv2.drawContours(frame_copy, [zone.contour], -1, zone.color, thickness)
|
||||
|
||||
|
||||
if draw_options.get('mask'):
|
||||
mask_overlay = np.where(self.camera_config.mask==[0])
|
||||
mask_overlay = np.where(self.camera_config.motion.mask==[0])
|
||||
frame_copy[mask_overlay] = [0,0,0]
|
||||
|
||||
|
||||
if draw_options.get('motion_boxes'):
|
||||
for m_box in motion_boxes:
|
||||
cv2.rectangle(frame_copy, (m_box[0], m_box[1]), (m_box[2], m_box[3]), (0,0,255), 2)
|
||||
|
||||
if draw_options.get('timestamp'):
|
||||
time_to_show = datetime.datetime.fromtimestamp(frame_time).strftime("%m/%d/%Y %H:%M:%S")
|
||||
cv2.putText(frame_copy, time_to_show, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, fontScale=.8, color=(255, 255, 255), thickness=2)
|
||||
|
||||
return frame_copy
|
||||
|
||||
def finished(self, obj_id):
|
||||
@@ -329,7 +332,7 @@ class CameraState():
|
||||
# call event handlers
|
||||
for c in self.callbacks['start']:
|
||||
c(self.name, new_obj, frame_time)
|
||||
|
||||
|
||||
for id in updated_ids:
|
||||
updated_obj = self.tracked_objects[id]
|
||||
significant_update = updated_obj.update(frame_time, current_detections[id])
|
||||
@@ -342,7 +345,7 @@ class CameraState():
|
||||
# call event handlers
|
||||
for c in self.callbacks['update']:
|
||||
c(self.name, updated_obj, frame_time)
|
||||
|
||||
|
||||
for id in removed_ids:
|
||||
# publish events to mqtt
|
||||
removed_obj = self.tracked_objects[id]
|
||||
@@ -361,9 +364,9 @@ class CameraState():
|
||||
if object_type in self.best_objects:
|
||||
current_best = self.best_objects[object_type]
|
||||
now = datetime.datetime.now().timestamp()
|
||||
# if the object is a higher score than the current best score
|
||||
# if the object is a higher score than the current best score
|
||||
# or the current object is older than desired, use the new object
|
||||
if (is_better_thumbnail(current_best.thumbnail_data, obj.thumbnail_data, self.camera_config.frame_shape)
|
||||
if (is_better_thumbnail(current_best.thumbnail_data, obj.thumbnail_data, self.camera_config.frame_shape)
|
||||
or (now - current_best.thumbnail_data['frame_time']) > self.camera_config.best_image_timeout):
|
||||
self.best_objects[object_type] = obj
|
||||
for c in self.callbacks['snapshot']:
|
||||
@@ -372,13 +375,13 @@ class CameraState():
|
||||
self.best_objects[object_type] = obj
|
||||
for c in self.callbacks['snapshot']:
|
||||
c(self.name, self.best_objects[object_type], frame_time)
|
||||
|
||||
|
||||
# update overall camera state for each object type
|
||||
obj_counter = Counter()
|
||||
for obj in self.tracked_objects.values():
|
||||
if not obj.false_positive:
|
||||
obj_counter[obj.obj_data['label']] += 1
|
||||
|
||||
|
||||
# report on detected objects
|
||||
for obj_name, count in obj_counter.items():
|
||||
if count != self.object_counts[obj_name]:
|
||||
@@ -394,14 +397,14 @@ class CameraState():
|
||||
c(self.name, obj_name, 0)
|
||||
for c in self.callbacks['snapshot']:
|
||||
c(self.name, self.best_objects[obj_name], frame_time)
|
||||
|
||||
|
||||
# cleanup thumbnail frame cache
|
||||
current_thumb_frames = set([obj.thumbnail_data['frame_time'] for obj in self.tracked_objects.values() if not obj.false_positive])
|
||||
current_best_frames = set([obj.thumbnail_data['frame_time'] for obj in self.best_objects.values()])
|
||||
thumb_frames_to_delete = [t for t in self.frame_cache.keys() if not t in current_thumb_frames and not t in current_best_frames]
|
||||
for t in thumb_frames_to_delete:
|
||||
del self.frame_cache[t]
|
||||
|
||||
|
||||
with self.current_frame_lock:
|
||||
self._current_frame = current_frame
|
||||
if not self.previous_frame_id is None:
|
||||
@@ -427,18 +430,40 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
|
||||
def update(camera, obj: TrackedObject, current_frame_time):
|
||||
after = obj.to_dict()
|
||||
message = { 'before': obj.previous, 'after': after }
|
||||
message = { 'before': obj.previous, 'after': after, 'type': 'new' if obj.previous['false_positive'] else 'update' }
|
||||
self.client.publish(f"{self.topic_prefix}/events", json.dumps(message), retain=False)
|
||||
obj.previous = after
|
||||
|
||||
def end(camera, obj: TrackedObject, current_frame_time):
|
||||
snapshot_config = self.config.cameras[camera].snapshots
|
||||
event_data = obj.to_dict(include_thumbnail=True)
|
||||
event_data['has_snapshot'] = False
|
||||
if not obj.false_positive:
|
||||
message = { 'before': obj.previous, 'after': obj.to_dict() }
|
||||
message = { 'before': obj.previous, 'after': obj.to_dict(), 'type': 'end' }
|
||||
self.client.publish(f"{self.topic_prefix}/events", json.dumps(message), retain=False)
|
||||
self.event_queue.put(('end', camera, obj.to_dict(include_thumbnail=True)))
|
||||
# write snapshot to disk if enabled
|
||||
if snapshot_config.enabled:
|
||||
jpg_bytes = obj.get_jpg_bytes(
|
||||
timestamp=snapshot_config.timestamp,
|
||||
bounding_box=snapshot_config.bounding_box,
|
||||
crop=snapshot_config.crop,
|
||||
height=snapshot_config.height
|
||||
)
|
||||
with open(os.path.join(CLIPS_DIR, f"{camera}-{obj.obj_data['id']}.jpg"), 'wb') as j:
|
||||
j.write(jpg_bytes)
|
||||
event_data['has_snapshot'] = True
|
||||
self.event_queue.put(('end', camera, event_data))
|
||||
|
||||
def snapshot(camera, obj: TrackedObject, current_frame_time):
|
||||
self.client.publish(f"{self.topic_prefix}/{camera}/{obj.obj_data['label']}/snapshot", obj.get_jpg_bytes(), retain=True)
|
||||
mqtt_config = self.config.cameras[camera].mqtt
|
||||
if mqtt_config.enabled:
|
||||
jpg_bytes = obj.get_jpg_bytes(
|
||||
timestamp=mqtt_config.timestamp,
|
||||
bounding_box=mqtt_config.bounding_box,
|
||||
crop=mqtt_config.crop,
|
||||
height=mqtt_config.height
|
||||
)
|
||||
self.client.publish(f"{self.topic_prefix}/{camera}/{obj.obj_data['label']}/snapshot", jpg_bytes, retain=True)
|
||||
|
||||
def object_status(camera, object_name, status):
|
||||
self.client.publish(f"{self.topic_prefix}/{camera}/{object_name}", status, retain=False)
|
||||
@@ -461,7 +486,7 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
# }
|
||||
# }
|
||||
self.zone_data = defaultdict(lambda: defaultdict(lambda: {}))
|
||||
|
||||
|
||||
def get_best(self, camera, label):
|
||||
# TODO: need a lock here
|
||||
camera_state = self.camera_states[camera]
|
||||
@@ -472,7 +497,7 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
return best
|
||||
else:
|
||||
return {}
|
||||
|
||||
|
||||
def get_current_frame(self, camera, draw_options={}):
|
||||
return self.camera_states[camera].get_current_frame(draw_options)
|
||||
|
||||
@@ -499,7 +524,7 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
for obj in camera_state.tracked_objects.values():
|
||||
if zone in obj.current_zones and not obj.false_positive:
|
||||
obj_counter[obj.obj_data['label']] += 1
|
||||
|
||||
|
||||
# update counts and publish status
|
||||
for label in set(list(self.zone_data[zone].keys()) + list(obj_counter.keys())):
|
||||
# if we have previously published a count for this zone/label
|
||||
|
||||
@@ -45,9 +45,9 @@ class RecordingMaintainer(threading.Thread):
|
||||
|
||||
files_in_use = []
|
||||
for process in psutil.process_iter():
|
||||
if process.name() != 'ffmpeg':
|
||||
continue
|
||||
try:
|
||||
if process.name() != 'ffmpeg':
|
||||
continue
|
||||
flist = process.open_files()
|
||||
if flist:
|
||||
for nt in flist:
|
||||
@@ -98,9 +98,9 @@ class RecordingMaintainer(threading.Thread):
|
||||
delete_before[name] = datetime.datetime.now().timestamp() - SECONDS_IN_DAY*camera.record.retain_days
|
||||
|
||||
for p in Path('/media/frigate/recordings').rglob("*.mp4"):
|
||||
if not p.parent in delete_before:
|
||||
if not p.parent.name in delete_before:
|
||||
continue
|
||||
if p.stat().st_mtime < delete_before[p.parent]:
|
||||
if p.stat().st_mtime < delete_before[p.parent.name]:
|
||||
p.unlink(missing_ok=True)
|
||||
|
||||
def run(self):
|
||||
@@ -122,4 +122,4 @@ class RecordingMaintainer(threading.Thread):
|
||||
self.move_files()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
70
frigate/stats.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.version import VERSION
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def stats_init(camera_metrics, detectors):
|
||||
stats_tracking = {
|
||||
'camera_metrics': camera_metrics,
|
||||
'detectors': detectors,
|
||||
'started': int(time.time())
|
||||
}
|
||||
return stats_tracking
|
||||
|
||||
def stats_snapshot(stats_tracking):
|
||||
camera_metrics = stats_tracking['camera_metrics']
|
||||
stats = {}
|
||||
|
||||
total_detection_fps = 0
|
||||
|
||||
for name, camera_stats in camera_metrics.items():
|
||||
total_detection_fps += camera_stats['detection_fps'].value
|
||||
stats[name] = {
|
||||
'camera_fps': round(camera_stats['camera_fps'].value, 2),
|
||||
'process_fps': round(camera_stats['process_fps'].value, 2),
|
||||
'skipped_fps': round(camera_stats['skipped_fps'].value, 2),
|
||||
'detection_fps': round(camera_stats['detection_fps'].value, 2),
|
||||
'pid': camera_stats['process'].pid,
|
||||
'capture_pid': camera_stats['capture_process'].pid
|
||||
}
|
||||
|
||||
stats['detectors'] = {}
|
||||
for name, detector in stats_tracking["detectors"].items():
|
||||
stats['detectors'][name] = {
|
||||
'inference_speed': round(detector.avg_inference_speed.value * 1000, 2),
|
||||
'detection_start': detector.detection_start.value,
|
||||
'pid': detector.detect_process.pid
|
||||
}
|
||||
stats['detection_fps'] = round(total_detection_fps, 2)
|
||||
|
||||
stats['service'] = {
|
||||
'uptime': (int(time.time()) - stats_tracking['started']),
|
||||
'version': VERSION
|
||||
}
|
||||
|
||||
return stats
|
||||
|
||||
class StatsEmitter(threading.Thread):
|
||||
def __init__(self, config: FrigateConfig, stats_tracking, mqtt_client, topic_prefix, stop_event):
|
||||
threading.Thread.__init__(self)
|
||||
self.name = 'frigate_stats_emitter'
|
||||
self.config = config
|
||||
self.stats_tracking = stats_tracking
|
||||
self.mqtt_client = mqtt_client
|
||||
self.topic_prefix = topic_prefix
|
||||
self.stop_event = stop_event
|
||||
|
||||
def run(self):
|
||||
time.sleep(10)
|
||||
while True:
|
||||
if self.stop_event.is_set():
|
||||
logger.info(f"Exiting watchdog...")
|
||||
break
|
||||
stats = stats_snapshot(self.stats_tracking)
|
||||
self.mqtt_client.publish(f"{self.topic_prefix}/stats", json.dumps(stats), retain=False)
|
||||
time.sleep(self.config.mqtt.stats_interval)
|
||||
@@ -191,12 +191,12 @@ class TestConfig(TestCase):
|
||||
frigate_config = FrigateConfig(config=config)
|
||||
assert('-re' in frigate_config.cameras['back'].ffmpeg_cmds[0]['cmd'])
|
||||
|
||||
def test_inherit_save_clips_retention(self):
|
||||
def test_inherit_clips_retention(self):
|
||||
config = {
|
||||
'mqtt': {
|
||||
'host': 'mqtt'
|
||||
},
|
||||
'save_clips': {
|
||||
'clips': {
|
||||
'retain': {
|
||||
'default': 20,
|
||||
'objects': {
|
||||
@@ -217,14 +217,14 @@ class TestConfig(TestCase):
|
||||
}
|
||||
}
|
||||
frigate_config = FrigateConfig(config=config)
|
||||
assert(frigate_config.cameras['back'].save_clips.retain.objects['person'] == 30)
|
||||
assert(frigate_config.cameras['back'].clips.retain.objects['person'] == 30)
|
||||
|
||||
def test_roles_listed_twice_throws_error(self):
|
||||
config = {
|
||||
'mqtt': {
|
||||
'host': 'mqtt'
|
||||
},
|
||||
'save_clips': {
|
||||
'clips': {
|
||||
'retain': {
|
||||
'default': 20,
|
||||
'objects': {
|
||||
@@ -252,7 +252,7 @@ class TestConfig(TestCase):
|
||||
'mqtt': {
|
||||
'host': 'mqtt'
|
||||
},
|
||||
'save_clips': {
|
||||
'clips': {
|
||||
'retain': {
|
||||
'default': 20,
|
||||
'objects': {
|
||||
@@ -279,12 +279,12 @@ class TestConfig(TestCase):
|
||||
}
|
||||
self.assertRaises(vol.MultipleInvalid, lambda: FrigateConfig(config=config))
|
||||
|
||||
def test_save_clips_should_default_to_global_objects(self):
|
||||
def test_clips_should_default_to_global_objects(self):
|
||||
config = {
|
||||
'mqtt': {
|
||||
'host': 'mqtt'
|
||||
},
|
||||
'save_clips': {
|
||||
'clips': {
|
||||
'retain': {
|
||||
'default': 20,
|
||||
'objects': {
|
||||
@@ -304,16 +304,14 @@ class TestConfig(TestCase):
|
||||
},
|
||||
'height': 1080,
|
||||
'width': 1920,
|
||||
'save_clips': {
|
||||
'clips': {
|
||||
'enabled': True
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
config = FrigateConfig(config=config)
|
||||
assert(len(config.cameras['back'].save_clips.objects) == 2)
|
||||
assert('dog' in config.cameras['back'].save_clips.objects)
|
||||
assert('person' in config.cameras['back'].save_clips.objects)
|
||||
assert(config.cameras['back'].clips.objects is None)
|
||||
|
||||
def test_role_assigned_but_not_enabled(self):
|
||||
json_config = {
|
||||
@@ -325,7 +323,7 @@ class TestConfig(TestCase):
|
||||
'ffmpeg': {
|
||||
'inputs': [
|
||||
{ 'path': 'rtsp://10.0.0.1:554/video', 'roles': ['detect', 'rtmp'] },
|
||||
{ 'path': 'rtsp://10.0.0.1:554/clips', 'roles': ['clips'] }
|
||||
{ 'path': 'rtsp://10.0.0.1:554/record', 'roles': ['record'] }
|
||||
]
|
||||
},
|
||||
'height': 1080,
|
||||
|
||||
@@ -2,6 +2,7 @@ import collections
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import signal
|
||||
import subprocess as sp
|
||||
import threading
|
||||
@@ -15,6 +16,8 @@ import cv2
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def draw_box_with_label(frame, x_min, y_min, x_max, y_max, label, info, thickness=2, color=None, position='ul'):
|
||||
if color is None:
|
||||
@@ -288,6 +291,24 @@ def print_stack(sig, frame):
|
||||
def listen():
|
||||
signal.signal(signal.SIGUSR1, print_stack)
|
||||
|
||||
def create_mask(frame_shape, mask):
|
||||
mask_img = np.zeros(frame_shape, np.uint8)
|
||||
mask_img[:] = 255
|
||||
|
||||
if isinstance(mask, list):
|
||||
for m in mask:
|
||||
add_mask(m, mask_img)
|
||||
|
||||
elif isinstance(mask, str):
|
||||
add_mask(mask, mask_img)
|
||||
|
||||
return mask_img
|
||||
|
||||
def add_mask(mask, mask_img):
|
||||
points = mask.split(',')
|
||||
contour = np.array([[int(points[i]), int(points[i+1])] for i in range(0, len(points), 2)])
|
||||
cv2.fillPoly(mask_img, pts=[contour], color=(0))
|
||||
|
||||
class FrameManager(ABC):
|
||||
@abstractmethod
|
||||
def create(self, name, size) -> AnyStr:
|
||||
|
||||
@@ -13,6 +13,7 @@ import signal
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from setproctitle import setproctitle
|
||||
from typing import Dict, List
|
||||
|
||||
import cv2
|
||||
@@ -30,7 +31,7 @@ from frigate.util import (EventsPerSecond, FrameManager,
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def filtered(obj, objects_to_track, object_filters, mask=None):
|
||||
def filtered(obj, objects_to_track, object_filters):
|
||||
object_name = obj[0]
|
||||
|
||||
if not object_name in objects_to_track:
|
||||
@@ -53,14 +54,15 @@ def filtered(obj, objects_to_track, object_filters, mask=None):
|
||||
if obj_settings.min_score > obj[1]:
|
||||
return True
|
||||
|
||||
# compute the coordinates of the object and make sure
|
||||
# the location isnt outside the bounds of the image (can happen from rounding)
|
||||
y_location = min(int(obj[2][3]), len(mask)-1)
|
||||
x_location = min(int((obj[2][2]-obj[2][0])/2.0)+obj[2][0], len(mask[0])-1)
|
||||
if not obj_settings.mask is None:
|
||||
# compute the coordinates of the object and make sure
|
||||
# the location isnt outside the bounds of the image (can happen from rounding)
|
||||
y_location = min(int(obj[2][3]), len(obj_settings.mask)-1)
|
||||
x_location = min(int((obj[2][2]-obj[2][0])/2.0)+obj[2][0], len(obj_settings.mask[0])-1)
|
||||
|
||||
# if the object is in a masked location, don't add it to detected objects
|
||||
if (not mask is None) and (mask[y_location][x_location] == 0):
|
||||
return True
|
||||
# if the object is in a masked location, don't add it to detected objects
|
||||
if obj_settings.mask[y_location][x_location] == 0:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@@ -249,16 +251,17 @@ def track_camera(name, config: CameraConfig, model_shape, detection_queue, resul
|
||||
signal.signal(signal.SIGINT, receiveSignal)
|
||||
|
||||
threading.current_thread().name = f"process:{name}"
|
||||
setproctitle(f"frigate.process:{name}")
|
||||
listen()
|
||||
|
||||
frame_queue = process_info['frame_queue']
|
||||
detection_enabled = process_info['detection_enabled']
|
||||
|
||||
frame_shape = config.frame_shape
|
||||
objects_to_track = config.objects.track
|
||||
object_filters = config.objects.filters
|
||||
mask = config.mask
|
||||
|
||||
motion_detector = MotionDetector(frame_shape, mask, config.motion)
|
||||
motion_detector = MotionDetector(frame_shape, config.motion)
|
||||
object_detector = RemoteObjectDetector(name, '/labelmap.txt', detection_queue, result_connection, model_shape)
|
||||
|
||||
object_tracker = ObjectTracker(config.detect)
|
||||
@@ -266,7 +269,7 @@ def track_camera(name, config: CameraConfig, model_shape, detection_queue, resul
|
||||
frame_manager = SharedMemoryFrameManager()
|
||||
|
||||
process_frames(name, frame_queue, frame_shape, model_shape, frame_manager, motion_detector, object_detector,
|
||||
object_tracker, detected_objects_queue, process_info, objects_to_track, object_filters, mask, stop_event)
|
||||
object_tracker, detected_objects_queue, process_info, objects_to_track, object_filters, detection_enabled, stop_event)
|
||||
|
||||
logger.info(f"{name}: exiting subprocess")
|
||||
|
||||
@@ -276,7 +279,7 @@ def reduce_boxes(boxes):
|
||||
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 detect(object_detector, frame, model_shape, region, objects_to_track, object_filters, mask):
|
||||
def detect(object_detector, frame, model_shape, region, objects_to_track, object_filters):
|
||||
tensor_input = create_tensor_input(frame, model_shape, region)
|
||||
|
||||
detections = []
|
||||
@@ -294,7 +297,7 @@ def detect(object_detector, frame, model_shape, region, objects_to_track, object
|
||||
(x_max-x_min)*(y_max-y_min),
|
||||
region)
|
||||
# apply object filters
|
||||
if filtered(det, objects_to_track, object_filters, mask):
|
||||
if filtered(det, objects_to_track, object_filters):
|
||||
continue
|
||||
detections.append(det)
|
||||
return detections
|
||||
@@ -303,7 +306,7 @@ def process_frames(camera_name: str, frame_queue: mp.Queue, frame_shape, model_s
|
||||
frame_manager: FrameManager, motion_detector: MotionDetector,
|
||||
object_detector: RemoteObjectDetector, object_tracker: ObjectTracker,
|
||||
detected_objects_queue: mp.Queue, process_info: Dict,
|
||||
objects_to_track: List[str], object_filters, mask, stop_event,
|
||||
objects_to_track: List[str], object_filters, detection_enabled: mp.Value, stop_event,
|
||||
exit_on_empty: bool = False):
|
||||
|
||||
fps = process_info['process_fps']
|
||||
@@ -334,6 +337,14 @@ def process_frames(camera_name: str, frame_queue: mp.Queue, frame_shape, model_s
|
||||
logger.info(f"{camera_name}: frame {frame_time} is not in memory store.")
|
||||
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
|
||||
motion_boxes = motion_detector.detect(frame)
|
||||
|
||||
@@ -356,7 +367,7 @@ def process_frames(camera_name: str, frame_queue: mp.Queue, frame_shape, model_s
|
||||
# resize regions and detect
|
||||
detections = []
|
||||
for region in regions:
|
||||
detections.extend(detect(object_detector, frame, model_shape, region, objects_to_track, object_filters, mask))
|
||||
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
|
||||
@@ -391,7 +402,7 @@ def process_frames(camera_name: str, frame_queue: mp.Queue, frame_shape, model_s
|
||||
|
||||
regions.append(region)
|
||||
|
||||
selected_objects.extend(detect(object_detector, frame, model_shape, region, objects_to_track, object_filters, mask))
|
||||
selected_objects.extend(detect(object_detector, frame, model_shape, region, objects_to_track, object_filters))
|
||||
|
||||
refining = True
|
||||
else:
|
||||
@@ -408,11 +419,11 @@ def process_frames(camera_name: str, frame_queue: mp.Queue, frame_shape, model_s
|
||||
|
||||
# add to the queue if not full
|
||||
if(detected_objects_queue.full()):
|
||||
frame_manager.delete(f"{camera_name}{frame_time}")
|
||||
continue
|
||||
frame_manager.delete(f"{camera_name}{frame_time}")
|
||||
continue
|
||||
else:
|
||||
fps_tracker.update()
|
||||
fps.value = fps_tracker.eps()
|
||||
detected_objects_queue.put((camera_name, frame_time, object_tracker.tracked_objects, motion_boxes, regions))
|
||||
detection_fps.value = object_detector.fps.eps()
|
||||
frame_manager.close(f"{camera_name}{frame_time}")
|
||||
fps_tracker.update()
|
||||
fps.value = fps_tracker.eps()
|
||||
detected_objects_queue.put((camera_name, frame_time, object_tracker.tracked_objects, motion_boxes, regions))
|
||||
detection_fps.value = object_detector.fps.eps()
|
||||
frame_manager.close(f"{camera_name}{frame_time}")
|
||||
|
||||
41
migrations/001_create_events_table.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Peewee migrations -- 001_create_events_table.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['model_name'] # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.python(func, *args, **kwargs) # Run python code
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
import peewee as pw
|
||||
from decimal import ROUND_HALF_EVEN
|
||||
|
||||
try:
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
SQL = pw.SQL
|
||||
|
||||
def migrate(migrator, database, fake=False, **kwargs):
|
||||
migrator.sql('CREATE TABLE IF NOT EXISTS "event" ("id" VARCHAR(30) NOT NULL PRIMARY KEY, "label" VARCHAR(20) NOT NULL, "camera" VARCHAR(20) NOT NULL, "start_time" DATETIME NOT NULL, "end_time" DATETIME NOT NULL, "top_score" REAL NOT NULL, "false_positive" INTEGER NOT NULL, "zones" JSON NOT NULL, "thumbnail" TEXT NOT NULL)')
|
||||
migrator.sql('CREATE INDEX IF NOT EXISTS "event_label" ON "event" ("label")')
|
||||
migrator.sql('CREATE INDEX IF NOT EXISTS "event_camera" ON "event" ("camera")')
|
||||
|
||||
def rollback(migrator, database, fake=False, **kwargs):
|
||||
pass
|
||||
41
migrations/002_add_clip_snapshot.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Peewee migrations -- 002_add_clip_snapshot.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 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.add_fields(Event, has_clip=pw.BooleanField(default=True), has_snapshot=pw.BooleanField(default=True))
|
||||
|
||||
def rollback(migrator, database, fake=False, **kwargs):
|
||||
migrator.remove_fields(Event, ['has_clip', 'has_snapshot'])
|
||||
@@ -96,13 +96,19 @@ http {
|
||||
root /media/frigate;
|
||||
}
|
||||
|
||||
location / {
|
||||
location /api/ {
|
||||
add_header 'Access-Control-Allow-Origin' '*';
|
||||
proxy_pass http://frigate_api/;
|
||||
proxy_pass_request_headers on;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
root /opt/frigate/web;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,4 +125,4 @@ rtmp {
|
||||
meta copy;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1
web/.dockerignore
Normal file
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
8
web/README.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Frigate Web UI
|
||||
|
||||
## Development
|
||||
|
||||
1. Build the docker images in the root of the repository `make amd64_all` (or appropriate for your system)
|
||||
2. Create a config file in `config/`
|
||||
3. Run the container: `docker run --rm --name frigate --privileged -v $PWD/config:/config:ro -v /etc/localtime:/etc/localtime:ro -p 5000:5000 frigate`
|
||||
4. Run the dev ui: `cd web && npm run start`
|
||||
8497
web/package-lock.json
generated
Normal file
24
web/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "frigate",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "cross-env SNOWPACK_PUBLIC_API_HOST=http://localhost:5000 snowpack dev",
|
||||
"prebuild": "rimraf build",
|
||||
"build": "snowpack build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prefresh/snowpack": "^3.0.1",
|
||||
"@snowpack/plugin-optimize": "^0.2.13",
|
||||
"@snowpack/plugin-postcss": "^1.1.0",
|
||||
"@snowpack/plugin-webpack": "^2.3.0",
|
||||
"autoprefixer": "^10.2.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"postcss": "^8.2.2",
|
||||
"postcss-cli": "^8.3.1",
|
||||
"preact": "^10.5.9",
|
||||
"preact-router": "^3.2.1",
|
||||
"rimraf": "^3.0.2",
|
||||
"snowpack": "^3.0.0",
|
||||
"tailwindcss": "^2.0.2"
|
||||
}
|
||||
}
|
||||
8
web/postcss.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
plugins: [
|
||||
require('tailwindcss'),
|
||||
require('autoprefixer'),
|
||||
],
|
||||
};
|
||||
BIN
web/public/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
web/public/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
web/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
web/public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 558 B |
BIN
web/public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 800 B |
BIN
web/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
web/public/favicon.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
21
web/public/index.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<title>Frigate</title>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#3b82f7" />
|
||||
<meta name="msapplication-TileColor" content="#3b82f7" />
|
||||
<meta name="theme-color" content="#ff0000" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<script type="module" src="/dist/index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
web/public/mstile-150x150.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
46
web/public/safari-pinned-tab.svg
Normal file
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="888.000000pt" height="888.000000pt" viewBox="0 0 888.000000 888.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<metadata>
|
||||
Created by potrace 1.11, written by Peter Selinger 2001-2013
|
||||
</metadata>
|
||||
<g transform="translate(0.000000,888.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M8228 8865 c-2 -2 -25 -6 -53 -9 -38 -5 -278 -56 -425 -91 -33 -7
|
||||
-381 -98 -465 -121 -49 -14 -124 -34 -165 -45 -67 -18 -485 -138 -615 -176
|
||||
-50 -14 -106 -30 -135 -37 -8 -2 -35 -11 -60 -19 -25 -8 -85 -27 -135 -42 -49
|
||||
-14 -101 -31 -115 -36 -14 -5 -34 -11 -45 -13 -11 -3 -65 -19 -120 -36 -55
|
||||
-18 -127 -40 -160 -50 -175 -53 -247 -77 -550 -178 -364 -121 -578 -200 -820
|
||||
-299 -88 -36 -214 -88 -280 -115 -66 -27 -129 -53 -140 -58 -11 -5 -67 -29
|
||||
-125 -54 -342 -144 -535 -259 -579 -343 -34 -66 7 -145 156 -299 229 -238 293
|
||||
-316 340 -413 38 -80 41 -152 10 -281 -57 -234 -175 -543 -281 -732 -98 -174
|
||||
-172 -239 -341 -297 -116 -40 -147 -52 -210 -80 -107 -49 -179 -107 -290 -236
|
||||
-51 -59 -179 -105 -365 -131 -19 -2 -48 -7 -65 -9 -16 -3 -50 -8 -75 -11 -69
|
||||
-9 -130 -39 -130 -63 0 -24 31 -46 78 -56 18 -4 139 -8 270 -10 250 -4 302
|
||||
-11 335 -44 19 -18 19 -23 7 -46 -19 -36 -198 -121 -490 -233 -850 -328 -914
|
||||
-354 -1159 -473 -185 -90 -337 -186 -395 -249 -60 -65 -67 -107 -62 -350 3
|
||||
-113 7 -216 10 -230 3 -14 7 -52 10 -85 7 -70 14 -128 21 -170 2 -16 7 -48 10
|
||||
-70 3 -22 11 -64 16 -94 6 -30 12 -64 14 -75 1 -12 5 -34 9 -51 3 -16 8 -39
|
||||
10 -50 12 -57 58 -258 71 -310 9 -33 18 -69 20 -79 25 -110 138 -416 216 -582
|
||||
21 -47 39 -87 39 -90 0 -7 217 -438 261 -521 109 -201 293 -501 347 -564 11
|
||||
-13 37 -44 56 -68 69 -82 126 -109 160 -75 26 25 14 65 -48 164 -138 218 -142
|
||||
245 -138 800 2 206 4 488 5 625 1 138 -1 293 -6 345 -28 345 -28 594 -1 760
|
||||
12 69 54 187 86 235 33 52 188 212 293 302 98 84 108 93 144 121 19 15 52 42
|
||||
75 61 78 64 302 229 426 313 248 169 483 297 600 326 53 14 205 6 365 -17 33
|
||||
-5 155 -8 270 -6 179 3 226 7 316 28 58 13 140 25 182 26 82 2 120 6 217 22
|
||||
73 12 97 16 122 18 12 1 23 21 38 70 l20 68 74 -17 c81 -20 155 -30 331 -45
|
||||
69 -6 132 -8 715 -20 484 -11 620 -8 729 16 85 19 131 63 98 96 -25 26 -104
|
||||
34 -302 32 -373 -2 -408 -1 -471 26 -90 37 2 102 171 120 33 3 76 8 95 10 19
|
||||
2 71 7 115 10 243 17 267 20 338 37 145 36 47 102 -203 137 -136 19 -262 25
|
||||
-490 22 -124 -2 -362 -4 -530 -4 l-305 -1 -56 26 c-65 31 -171 109 -238 176
|
||||
-52 51 -141 173 -141 191 0 6 -6 22 -14 34 -18 27 -54 165 -64 244 -12 98 -6
|
||||
322 12 414 9 47 29 127 45 176 26 80 58 218 66 278 1 11 6 47 10 80 3 33 8 70
|
||||
10 83 2 13 7 53 11 90 3 37 8 74 9 83 22 118 22 279 -1 464 -20 172 -20 172
|
||||
70 238 108 79 426 248 666 355 25 11 77 34 115 52 92 42 443 191 570 242 55
|
||||
22 109 44 120 48 24 11 130 52 390 150 199 75 449 173 500 195 17 7 118 50
|
||||
225 95 237 100 333 143 490 220 229 113 348 191 337 223 -3 10 -70 20 -79 12z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
19
web/public/site.webmanifest
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "",
|
||||
"short_name": "",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ff0000",
|
||||
"background_color": "#ff0000",
|
||||
"display": "standalone"
|
||||
}
|
||||
30
web/snowpack.config.js
Normal file
@@ -0,0 +1,30 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
mount: {
|
||||
public: { url: '/', static: true },
|
||||
src: { url: '/dist' },
|
||||
},
|
||||
plugins: [
|
||||
'@snowpack/plugin-postcss',
|
||||
'@prefresh/snowpack',
|
||||
[
|
||||
'@snowpack/plugin-optimize',
|
||||
{
|
||||
preloadModules: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
'@snowpack/plugin-webpack',
|
||||
{
|
||||
sourceMap: true,
|
||||
},
|
||||
],
|
||||
],
|
||||
packageOptions: {
|
||||
sourcemap: false,
|
||||
},
|
||||
buildOptions: {
|
||||
sourcemap: true,
|
||||
},
|
||||
};
|
||||
43
web/src/App.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { h } from 'preact';
|
||||
import Camera from './Camera';
|
||||
import CameraMap from './CameraMap';
|
||||
import Cameras from './Cameras';
|
||||
import Debug from './Debug';
|
||||
import Event from './Event';
|
||||
import Events from './Events';
|
||||
import { Router } from 'preact-router';
|
||||
import Sidebar from './Sidebar';
|
||||
import { ApiHost, Config } from './context';
|
||||
import { useContext, useEffect, useState } from 'preact/hooks';
|
||||
|
||||
export default function App() {
|
||||
const apiHost = useContext(ApiHost);
|
||||
const [config, setConfig] = useState(null);
|
||||
|
||||
useEffect(async () => {
|
||||
const response = await fetch(`${apiHost}/api/config`);
|
||||
const data = response.ok ? await response.json() : {};
|
||||
setConfig(data);
|
||||
}, []);
|
||||
|
||||
return !config ? (
|
||||
<div />
|
||||
) : (
|
||||
<Config.Provider value={config}>
|
||||
<div className="md:flex flex-col md:flex-row md:min-h-screen w-full bg-gray-100 dark:bg-gray-800 ">
|
||||
<Sidebar />
|
||||
<div className="p-4">
|
||||
<Router>
|
||||
<CameraMap path="/cameras/:camera/editor" />
|
||||
<Camera path="/cameras/:camera" />
|
||||
<Event path="/events/:eventId" />
|
||||
<Events path="/events" />
|
||||
<Debug path="/debug" />
|
||||
<Cameras path="/" />
|
||||
</Router>
|
||||
</div>
|
||||
</div>
|
||||
</Config.Provider>
|
||||
);
|
||||
return;
|
||||
}
|
||||
77
web/src/Camera.jsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { h } from 'preact';
|
||||
import Heading from './components/Heading';
|
||||
import Link from './components/Link';
|
||||
import Switch from './components/Switch';
|
||||
import { route } from 'preact-router';
|
||||
import { useCallback, useContext } from 'preact/hooks';
|
||||
import { ApiHost, Config } from './context';
|
||||
|
||||
export default function Camera({ camera, url }) {
|
||||
const config = useContext(Config);
|
||||
const apiHost = useContext(ApiHost);
|
||||
|
||||
if (!(camera in config.cameras)) {
|
||||
return <div>{`No camera named ${camera}`}</div>;
|
||||
}
|
||||
|
||||
const cameraConfig = config.cameras[camera];
|
||||
|
||||
const { pathname, searchParams } = new URL(`${window.location.protocol}//${window.location.host}${url}`);
|
||||
const searchParamsString = searchParams.toString();
|
||||
|
||||
const handleSetOption = useCallback(
|
||||
(id, value) => {
|
||||
searchParams.set(id, value ? 1 : 0);
|
||||
route(`${pathname}?${searchParams.toString()}`, true);
|
||||
},
|
||||
[searchParams]
|
||||
);
|
||||
|
||||
function getBoolean(id) {
|
||||
return Boolean(parseInt(searchParams.get(id), 10));
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Heading size="2xl">{camera}</Heading>
|
||||
<img
|
||||
width={cameraConfig.width}
|
||||
height={cameraConfig.height}
|
||||
key={searchParamsString}
|
||||
src={`${apiHost}/api/${camera}?${searchParamsString}`}
|
||||
/>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 p-4">
|
||||
<Switch checked={getBoolean('bbox')} id="bbox" label="Bounding box" onChange={handleSetOption} />
|
||||
<Switch checked={getBoolean('timestamp')} id="timestamp" label="Timestamp" onChange={handleSetOption} />
|
||||
<Switch checked={getBoolean('zones')} id="zones" label="Zones" onChange={handleSetOption} />
|
||||
<Switch checked={getBoolean('mask')} id="mask" label="Masks" onChange={handleSetOption} />
|
||||
<Switch checked={getBoolean('motion')} id="motion" label="Motion boxes" onChange={handleSetOption} />
|
||||
<Switch checked={getBoolean('regions')} id="regions" label="Regions" onChange={handleSetOption} />
|
||||
</div>
|
||||
<div>
|
||||
<Heading size="sm">Tracked objects</Heading>
|
||||
<ul className="flex flex-row flex-wrap space-x-4">
|
||||
{cameraConfig.objects.track.map((objectType) => {
|
||||
return (
|
||||
<li key={objectType}>
|
||||
<Link href={`/events?camera=${camera}&label=${objectType}`}>
|
||||
<span className="capitalize">{objectType}</span>
|
||||
<img src={`${apiHost}/api/${camera}/${objectType}/best.jpg?crop=1&h=150`} />
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Heading size="sm">Options</Heading>
|
||||
<ul>
|
||||
<li>
|
||||
<Link href={`/cameras/${camera}/editor`}>Mask & Zone creator</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
574
web/src/CameraMap.jsx
Normal file
@@ -0,0 +1,574 @@
|
||||
import { h } from 'preact';
|
||||
import Button from './components/Button';
|
||||
import Heading from './components/Heading';
|
||||
import Switch from './components/Switch';
|
||||
import { route } from 'preact-router';
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import { ApiHost, Config } from './context';
|
||||
|
||||
export default function CameraMasks({ camera, url }) {
|
||||
const config = useContext(Config);
|
||||
const apiHost = useContext(ApiHost);
|
||||
const imageRef = useRef(null);
|
||||
const [imageScale, setImageScale] = useState(1);
|
||||
|
||||
if (!(camera in config.cameras)) {
|
||||
return <div>{`No camera named ${camera}`}</div>;
|
||||
}
|
||||
|
||||
const cameraConfig = config.cameras[camera];
|
||||
const {
|
||||
width,
|
||||
height,
|
||||
motion: { mask: motionMask },
|
||||
objects: { filters: objectFilters },
|
||||
zones,
|
||||
} = cameraConfig;
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageRef.current) {
|
||||
return;
|
||||
}
|
||||
const scaledWidth = imageRef.current.width;
|
||||
const scale = scaledWidth / width;
|
||||
setImageScale(scale);
|
||||
}, [imageRef.current, setImageScale]);
|
||||
|
||||
const [motionMaskPoints, setMotionMaskPoints] = useState(
|
||||
Array.isArray(motionMask)
|
||||
? motionMask.map((mask) => getPolylinePoints(mask))
|
||||
: motionMask
|
||||
? [getPolylinePoints(motionMask)]
|
||||
: []
|
||||
);
|
||||
|
||||
const [zonePoints, setZonePoints] = useState(
|
||||
Object.keys(zones).reduce((memo, zone) => ({ ...memo, [zone]: getPolylinePoints(zones[zone].coordinates) }), {})
|
||||
);
|
||||
|
||||
const [objectMaskPoints, setObjectMaskPoints] = useState(
|
||||
Object.keys(objectFilters).reduce(
|
||||
(memo, name) => ({
|
||||
...memo,
|
||||
[name]: Array.isArray(objectFilters[name].mask)
|
||||
? objectFilters[name].mask.map((mask) => getPolylinePoints(mask))
|
||||
: objectFilters[name].mask
|
||||
? [getPolylinePoints(objectFilters[name].mask)]
|
||||
: [],
|
||||
}),
|
||||
{}
|
||||
)
|
||||
);
|
||||
|
||||
const [editing, setEditing] = useState({ set: motionMaskPoints, key: 0, fn: setMotionMaskPoints });
|
||||
|
||||
const handleUpdateEditable = useCallback(
|
||||
(newPoints) => {
|
||||
let newSet;
|
||||
if (Array.isArray(editing.set)) {
|
||||
newSet = [...editing.set];
|
||||
newSet[editing.key] = newPoints;
|
||||
} else if (editing.subkey !== undefined) {
|
||||
newSet = { ...editing.set };
|
||||
newSet[editing.key][editing.subkey] = newPoints;
|
||||
} else {
|
||||
newSet = { ...editing.set, [editing.key]: newPoints };
|
||||
}
|
||||
editing.set = newSet;
|
||||
editing.fn(newSet);
|
||||
},
|
||||
[editing]
|
||||
);
|
||||
|
||||
const handleSelectEditable = useCallback(
|
||||
(name) => {
|
||||
setEditing(name);
|
||||
},
|
||||
[setEditing]
|
||||
);
|
||||
|
||||
const handleRemoveEditable = useCallback(
|
||||
(name) => {
|
||||
const filteredZonePoints = Object.keys(zonePoints)
|
||||
.filter((zoneName) => zoneName !== name)
|
||||
.reduce((memo, name) => {
|
||||
memo[name] = zonePoints[name];
|
||||
return memo;
|
||||
}, {});
|
||||
setZonePoints(filteredZonePoints);
|
||||
},
|
||||
[zonePoints, setZonePoints]
|
||||
);
|
||||
|
||||
// Motion mask methods
|
||||
const handleAddMask = useCallback(() => {
|
||||
const newMotionMaskPoints = [...motionMaskPoints, []];
|
||||
setMotionMaskPoints(newMotionMaskPoints);
|
||||
setEditing({ set: newMotionMaskPoints, key: newMotionMaskPoints.length - 1, fn: setMotionMaskPoints });
|
||||
}, [motionMaskPoints, setMotionMaskPoints]);
|
||||
|
||||
const handleEditMask = useCallback(
|
||||
(key) => {
|
||||
setEditing({ set: motionMaskPoints, key, fn: setMotionMaskPoints });
|
||||
},
|
||||
[setEditing, motionMaskPoints, setMotionMaskPoints]
|
||||
);
|
||||
|
||||
const handleRemoveMask = useCallback(
|
||||
(key) => {
|
||||
const newMotionMaskPoints = [...motionMaskPoints];
|
||||
newMotionMaskPoints.splice(key, 1);
|
||||
setMotionMaskPoints(newMotionMaskPoints);
|
||||
},
|
||||
[motionMaskPoints, setMotionMaskPoints]
|
||||
);
|
||||
|
||||
const handleCopyMotionMasks = useCallback(async () => {
|
||||
await window.navigator.clipboard.writeText(` motion:
|
||||
mask:
|
||||
${motionMaskPoints.map((mask, i) => ` - ${polylinePointsToPolyline(mask)}`).join('\n')}`);
|
||||
}, [motionMaskPoints]);
|
||||
|
||||
// Zone methods
|
||||
const handleEditZone = useCallback(
|
||||
(key) => {
|
||||
setEditing({ set: zonePoints, key, fn: setZonePoints });
|
||||
},
|
||||
[setEditing, zonePoints, setZonePoints]
|
||||
);
|
||||
|
||||
const handleAddZone = useCallback(() => {
|
||||
const n = Object.keys(zonePoints).filter((name) => name.startsWith('zone_')).length;
|
||||
const zoneName = `zone_${n}`;
|
||||
const newZonePoints = { ...zonePoints, [zoneName]: [] };
|
||||
setZonePoints(newZonePoints);
|
||||
setEditing({ set: newZonePoints, key: zoneName, fn: setZonePoints });
|
||||
}, [zonePoints, setZonePoints]);
|
||||
|
||||
const handleRemoveZone = useCallback(
|
||||
(key) => {
|
||||
const newZonePoints = { ...zonePoints };
|
||||
delete newZonePoints[key];
|
||||
setZonePoints(newZonePoints);
|
||||
},
|
||||
[zonePoints, setZonePoints]
|
||||
);
|
||||
|
||||
const handleCopyZones = useCallback(async () => {
|
||||
await window.navigator.clipboard.writeText(` zones:
|
||||
${Object.keys(zonePoints)
|
||||
.map(
|
||||
(zoneName) => ` ${zoneName}:
|
||||
coordinates: ${polylinePointsToPolyline(zonePoints[zoneName])}`
|
||||
)
|
||||
.join('\n')}`);
|
||||
}, [zonePoints]);
|
||||
|
||||
// Object methods
|
||||
const handleEditObjectMask = useCallback(
|
||||
(key, subkey) => {
|
||||
setEditing({ set: objectMaskPoints, key, subkey, fn: setObjectMaskPoints });
|
||||
},
|
||||
[setEditing, objectMaskPoints, setObjectMaskPoints]
|
||||
);
|
||||
|
||||
const handleAddObjectMask = useCallback(() => {
|
||||
const n = Object.keys(objectMaskPoints).filter((name) => name.startsWith('object_')).length;
|
||||
const newObjectName = `object_${n}`;
|
||||
const newObjectMaskPoints = { ...objectMaskPoints, [newObjectName]: [] };
|
||||
setObjectMaskPoints(newObjectMaskPoints);
|
||||
setEditing({ set: newObjectMaskPoints, key: newObjectName, subkey: 0, fn: setObjectMaskPoints });
|
||||
}, [objectMaskPoints, setObjectMaskPoints, setEditing]);
|
||||
|
||||
const handleRemoveObjectMask = useCallback(
|
||||
(key, subkey) => {
|
||||
const newObjectMaskPoints = { ...objectMaskPoints };
|
||||
delete newObjectMaskPoints[key];
|
||||
setObjectMaskPoints(newObjectMaskPoints);
|
||||
},
|
||||
[objectMaskPoints, setObjectMaskPoints]
|
||||
);
|
||||
|
||||
const handleCopyObjectMasks = useCallback(async () => {
|
||||
await window.navigator.clipboard.writeText(` objects:
|
||||
filters:
|
||||
${Object.keys(objectMaskPoints)
|
||||
.map((objectName) =>
|
||||
objectMaskPoints[objectName].length
|
||||
? ` ${objectName}:
|
||||
mask: ${polylinePointsToPolyline(objectMaskPoints[objectName])}`
|
||||
: ''
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join('\n')}`);
|
||||
}, [objectMaskPoints]);
|
||||
|
||||
return (
|
||||
<div class="flex-col space-y-4" style={`max-width: ${width}px`}>
|
||||
<Heading size="2xl">{camera} mask & zone creator</Heading>
|
||||
<p>
|
||||
This tool can help you create masks & zones for your {camera} camera. When done, copy each mask configuration
|
||||
into your <code className="font-mono">config.yml</code> file restart your Frigate instance to save your changes.
|
||||
</p>
|
||||
<div className="relative">
|
||||
<img ref={imageRef} width={width} height={height} src={`${apiHost}/api/${camera}/latest.jpg`} />
|
||||
<EditableMask
|
||||
onChange={handleUpdateEditable}
|
||||
points={editing.subkey ? editing.set[editing.key][editing.subkey] : editing.set[editing.key]}
|
||||
scale={imageScale}
|
||||
width={width}
|
||||
height={height}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-col space-y-4">
|
||||
<MaskValues
|
||||
editing={editing}
|
||||
title="Motion masks"
|
||||
onCopy={handleCopyMotionMasks}
|
||||
onCreate={handleAddMask}
|
||||
onEdit={handleEditMask}
|
||||
onRemove={handleRemoveMask}
|
||||
points={motionMaskPoints}
|
||||
yamlPrefix={'motion:\n mask:'}
|
||||
yamlKeyPrefix={maskYamlKeyPrefix}
|
||||
/>
|
||||
|
||||
<MaskValues
|
||||
editing={editing}
|
||||
title="Zones"
|
||||
onCopy={handleCopyZones}
|
||||
onCreate={handleAddZone}
|
||||
onEdit={handleEditZone}
|
||||
onRemove={handleRemoveZone}
|
||||
points={zonePoints}
|
||||
yamlPrefix="zones:"
|
||||
yamlKeyPrefix={zoneYamlKeyPrefix}
|
||||
/>
|
||||
|
||||
<MaskValues
|
||||
isMulti
|
||||
editing={editing}
|
||||
title="Object masks"
|
||||
onCopy={handleCopyObjectMasks}
|
||||
onCreate={handleAddObjectMask}
|
||||
onEdit={handleEditObjectMask}
|
||||
onRemove={handleRemoveObjectMask}
|
||||
points={objectMaskPoints}
|
||||
yamlPrefix={'objects:\n filters:'}
|
||||
yamlKeyPrefix={objectYamlKeyPrefix}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function maskYamlKeyPrefix(points) {
|
||||
return ` - `;
|
||||
}
|
||||
|
||||
function zoneYamlKeyPrefix(points, key) {
|
||||
return ` ${key}:
|
||||
coordinates: `;
|
||||
}
|
||||
|
||||
function objectYamlKeyPrefix(points, key, subkey) {
|
||||
return ` - `;
|
||||
}
|
||||
|
||||
function EditableMask({ onChange, points, scale, width, height }) {
|
||||
if (!points) {
|
||||
return null;
|
||||
}
|
||||
const boundingRef = useRef(null);
|
||||
|
||||
function boundedSize(value, maxValue) {
|
||||
return Math.min(Math.max(0, Math.round(value)), maxValue);
|
||||
}
|
||||
|
||||
const handleMovePoint = useCallback(
|
||||
(index, newX, newY) => {
|
||||
if (newX < 0 && newY < 0) {
|
||||
return;
|
||||
}
|
||||
const x = boundedSize(newX / scale, width);
|
||||
const y = boundedSize(newY / scale, height);
|
||||
const newPoints = [...points];
|
||||
newPoints[index] = [x, y];
|
||||
onChange(newPoints);
|
||||
},
|
||||
[scale, points]
|
||||
);
|
||||
|
||||
// Add a new point between the closest two other points
|
||||
const handleAddPoint = useCallback(
|
||||
(event) => {
|
||||
const { offsetX, offsetY } = event;
|
||||
const scaledX = boundedSize(offsetX / scale, width);
|
||||
const scaledY = boundedSize(offsetY / scale, height);
|
||||
const newPoint = [scaledX, scaledY];
|
||||
const closest = points.reduce((a, b, i) => {
|
||||
if (!a) {
|
||||
return b;
|
||||
}
|
||||
return distance(a, newPoint) < distance(b, newPoint) ? a : b;
|
||||
}, null);
|
||||
const index = points.indexOf(closest);
|
||||
const newPoints = [...points];
|
||||
newPoints.splice(index, 0, newPoint);
|
||||
console.log(points, newPoints);
|
||||
onChange(newPoints);
|
||||
},
|
||||
[scale, points, onChange]
|
||||
);
|
||||
|
||||
const handleRemovePoint = useCallback(
|
||||
(index) => {
|
||||
const newPoints = [...points];
|
||||
newPoints.splice(index, 1);
|
||||
onChange(newPoints);
|
||||
},
|
||||
[points, onChange]
|
||||
);
|
||||
|
||||
const scaledPoints = useMemo(() => scalePolylinePoints(points, scale), [points, scale]);
|
||||
|
||||
return (
|
||||
<div onclick={handleAddPoint}>
|
||||
{!scaledPoints
|
||||
? null
|
||||
: scaledPoints.map(([x, y], i) => (
|
||||
<PolyPoint
|
||||
boundingRef={boundingRef}
|
||||
index={i}
|
||||
onMove={handleMovePoint}
|
||||
onRemove={handleRemovePoint}
|
||||
x={x}
|
||||
y={y}
|
||||
/>
|
||||
))}
|
||||
<svg
|
||||
ref={boundingRef}
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="absolute"
|
||||
style="top: 0; left: 0; right: 0; bottom: 0;"
|
||||
>
|
||||
{!scaledPoints ? null : (
|
||||
<g>
|
||||
<polyline points={polylinePointsToPolyline(scaledPoints)} fill="rgba(244,0,0,0.5)" />
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MaskValues({
|
||||
isMulti = false,
|
||||
editing,
|
||||
title,
|
||||
onCopy,
|
||||
onCreate,
|
||||
onEdit,
|
||||
onRemove,
|
||||
points,
|
||||
yamlPrefix,
|
||||
yamlKeyPrefix,
|
||||
}) {
|
||||
const [showButtons, setShowButtons] = useState(false);
|
||||
|
||||
const handleMousein = useCallback(() => {
|
||||
setShowButtons(true);
|
||||
}, [setShowButtons]);
|
||||
|
||||
const handleMouseout = useCallback(
|
||||
(event) => {
|
||||
const el = event.toElement || event.relatedTarget;
|
||||
if (!el || el.parentNode === event.target) {
|
||||
return;
|
||||
}
|
||||
setShowButtons(false);
|
||||
},
|
||||
[setShowButtons]
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(event) => {
|
||||
const { key, subkey } = event.target.dataset;
|
||||
onEdit(key, subkey);
|
||||
},
|
||||
[onEdit]
|
||||
);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(event) => {
|
||||
const { key, subkey } = event.target.dataset;
|
||||
onRemove(key, subkey);
|
||||
},
|
||||
[onRemove]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="overflow-hidden rounded border-gray-500 border-solid border p-2"
|
||||
onmouseover={handleMousein}
|
||||
onmouseout={handleMouseout}
|
||||
>
|
||||
<div class="flex space-x-4">
|
||||
<Heading className="flex-grow self-center" size="base">
|
||||
{title}
|
||||
</Heading>
|
||||
<Button onClick={onCopy}>Copy</Button>
|
||||
<Button onClick={onCreate}>Add</Button>
|
||||
</div>
|
||||
<pre class="overflow-hidden font-mono text-gray-900 dark:text-gray-100">
|
||||
{yamlPrefix}
|
||||
{Object.keys(points).map((mainkey) => {
|
||||
if (isMulti) {
|
||||
return (
|
||||
<div>
|
||||
{` ${mainkey}:\n mask:\n`}
|
||||
{points[mainkey].map((item, subkey) => (
|
||||
<Item
|
||||
mainkey={mainkey}
|
||||
subkey={subkey}
|
||||
editing={editing}
|
||||
handleEdit={handleEdit}
|
||||
points={item}
|
||||
showButtons={showButtons}
|
||||
handleRemove={handleRemove}
|
||||
yamlKeyPrefix={yamlKeyPrefix}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Item
|
||||
mainkey={mainkey}
|
||||
editing={editing}
|
||||
handleEdit={handleEdit}
|
||||
points={points[mainkey]}
|
||||
showButtons={showButtons}
|
||||
handleRemove={handleRemove}
|
||||
yamlKeyPrefix={yamlKeyPrefix}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Item({ mainkey, subkey, editing, handleEdit, points, showButtons, handleRemove, yamlKeyPrefix }) {
|
||||
return (
|
||||
<span
|
||||
data-key={mainkey}
|
||||
data-subkey={subkey}
|
||||
className={`block hover:text-blue-400 cursor-pointer relative ${
|
||||
editing.key === mainkey && editing.subkey === subkey ? 'text-blue-800 dark:text-blue-600' : ''
|
||||
}`}
|
||||
onClick={handleEdit}
|
||||
title="Click to edit"
|
||||
>
|
||||
{`${yamlKeyPrefix(points, mainkey, subkey)}${polylinePointsToPolyline(points)}`}
|
||||
{showButtons ? (
|
||||
<Button
|
||||
className="absolute top-0 right-0"
|
||||
color="red"
|
||||
data-key={mainkey}
|
||||
data-subkey={subkey}
|
||||
onClick={handleRemove}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function distance([x0, y0], [x1, y1]) {
|
||||
return Math.sqrt(Math.pow(x0 - x1, 2) + Math.pow(y0 - y1, 2));
|
||||
}
|
||||
|
||||
function getPolylinePoints(polyline) {
|
||||
if (!polyline) {
|
||||
return;
|
||||
}
|
||||
|
||||
return polyline.split(',').reduce((memo, point, i) => {
|
||||
if (i % 2) {
|
||||
memo[memo.length - 1].push(parseInt(point, 10));
|
||||
} else {
|
||||
memo.push([parseInt(point, 10)]);
|
||||
}
|
||||
return memo;
|
||||
}, []);
|
||||
}
|
||||
|
||||
function scalePolylinePoints(polylinePoints, scale) {
|
||||
if (!polylinePoints) {
|
||||
return;
|
||||
}
|
||||
|
||||
return polylinePoints.map(([x, y]) => [Math.round(x * scale), Math.round(y * scale)]);
|
||||
}
|
||||
|
||||
function polylinePointsToPolyline(polylinePoints) {
|
||||
if (!polylinePoints) {
|
||||
return;
|
||||
}
|
||||
return polylinePoints.reduce((memo, [x, y]) => `${memo}${x},${y},`, '').replace(/,$/, '');
|
||||
}
|
||||
|
||||
const PolyPointRadius = 10;
|
||||
function PolyPoint({ boundingRef, index, x, y, onMove, onRemove }) {
|
||||
const [hidden, setHidden] = useState(false);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(event) => {
|
||||
if (event.target !== boundingRef.current && !boundingRef.current.contains(event.target)) {
|
||||
return;
|
||||
}
|
||||
onMove(index, event.layerX, event.layerY - PolyPointRadius);
|
||||
},
|
||||
[onMove, index, boundingRef.current]
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback(() => {
|
||||
boundingRef.current && boundingRef.current.addEventListener('dragover', handleDragOver, false);
|
||||
setHidden(true);
|
||||
}, [setHidden, boundingRef.current, handleDragOver]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
boundingRef.current && boundingRef.current.removeEventListener('dragover', handleDragOver);
|
||||
setHidden(false);
|
||||
}, [setHidden, boundingRef.current, handleDragOver]);
|
||||
|
||||
const handleRightClick = useCallback(
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
onRemove(index);
|
||||
},
|
||||
[onRemove, index]
|
||||
);
|
||||
|
||||
const handleClick = useCallback((event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${hidden ? 'opacity-0' : ''} bg-gray-900 rounded-full absolute z-20`}
|
||||
style={`top: ${y - PolyPointRadius}px; left: ${x - PolyPointRadius}px; width: 20px; height: 20px;`}
|
||||
draggable
|
||||
onclick={handleClick}
|
||||
oncontextmenu={handleRightClick}
|
||||
ondragstart={handleDragStart}
|
||||
ondragend={handleDragEnd}
|
||||
/>
|
||||
);
|
||||
}
|
||||
36
web/src/Cameras.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { h } from 'preact';
|
||||
import Events from './Events';
|
||||
import Heading from './components/Heading';
|
||||
import { route } from 'preact-router';
|
||||
import { useContext } from 'preact/hooks';
|
||||
import { ApiHost, Config } from './context';
|
||||
|
||||
export default function Cameras() {
|
||||
const config = useContext(Config);
|
||||
|
||||
if (!config.cameras) {
|
||||
return <p>loading…</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid lg:grid-cols-2 md:grid-cols-1 gap-4">
|
||||
{Object.keys(config.cameras).map((camera) => (
|
||||
<Camera name={camera} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Camera({ name }) {
|
||||
const apiHost = useContext(ApiHost);
|
||||
const href = `/cameras/${name}`;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-700 shadow-lg rounded-lg p-4 hover:bg-gray-300 hover:dark:bg-gray-500 dark:hover:text-gray-900">
|
||||
<a className="dark:hover:text-gray-900" href={href}>
|
||||
<Heading size="base">{name}</Heading>
|
||||
<img className="w-full" src={`${apiHost}/api/${name}/latest.jpg`} />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
web/src/Debug.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { h } from 'preact';
|
||||
import { ApiHost } from './context';
|
||||
import { useContext, useEffect, useState } from 'preact/hooks';
|
||||
|
||||
export default function Debug() {
|
||||
const apiHost = useContext(ApiHost);
|
||||
const [config, setConfig] = useState({});
|
||||
|
||||
useEffect(async () => {
|
||||
const response = await fetch(`${apiHost}/api/stats`);
|
||||
const data = response.ok ? await response.json() : {};
|
||||
setConfig(data);
|
||||
}, []);
|
||||
|
||||
return <pre>{JSON.stringify(config, null, 2)}</pre>;
|
||||
}
|
||||
45
web/src/Event.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { h } from 'preact';
|
||||
import { ApiHost } from './context';
|
||||
import Heading from './components/Heading';
|
||||
import { useContext, useEffect, useState } from 'preact/hooks';
|
||||
|
||||
export default function Event({ eventId }) {
|
||||
const apiHost = useContext(ApiHost);
|
||||
const [data, setData] = useState(null);
|
||||
|
||||
useEffect(async () => {
|
||||
const response = await fetch(`${apiHost}/api/events/${eventId}`);
|
||||
const data = response.ok ? await response.json() : null;
|
||||
setData(data);
|
||||
}, [apiHost, eventId]);
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div>
|
||||
<Heading>{eventId}</Heading>
|
||||
<p>loading…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const datetime = new Date(data.start_time * 1000);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Heading>
|
||||
{data.camera} {data.label} <span className="text-sm">{datetime.toLocaleString()}</span>
|
||||
</Heading>
|
||||
<img
|
||||
src={`${apiHost}/clips/${data.camera}-${eventId}.jpg`}
|
||||
alt={`${data.label} at ${(data.top_score * 100).toFixed(1)}% confidence`}
|
||||
/>
|
||||
{data.has_clip ? (
|
||||
<video className="w-96" src={`${apiHost}/clips/${data.camera}-${eventId}.mp4`} controls />
|
||||
) : (
|
||||
<p>No clip available</p>
|
||||
)}
|
||||
|
||||
<pre>{JSON.stringify(data, null, 2)}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
web/src/Events.jsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { h } from 'preact';
|
||||
import { ApiHost } from './context';
|
||||
import Heading from './components/Heading';
|
||||
import Link from './components/Link';
|
||||
import { route } from 'preact-router';
|
||||
import { Table, Thead, Tbody, Tfoot, Th, Tr, Td } from './components/Table';
|
||||
import { useCallback, useContext, useEffect, useState } from 'preact/hooks';
|
||||
|
||||
export default function Events({ url } = {}) {
|
||||
const apiHost = useContext(ApiHost);
|
||||
const [events, setEvents] = useState([]);
|
||||
|
||||
const searchParams = new URL(`${window.location.protocol}//${window.location.host}${url || '/events'}`).searchParams;
|
||||
const searchParamsString = searchParams.toString();
|
||||
|
||||
useEffect(async () => {
|
||||
const response = await fetch(`${apiHost}/api/events?${searchParamsString}`);
|
||||
const data = response.ok ? await response.json() : {};
|
||||
setEvents(data);
|
||||
}, [searchParamsString]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Heading>Events</Heading>
|
||||
<div className="flex flex-wrap space-x-2">
|
||||
{Array.from(searchParams.keys()).map((filterKey) => (
|
||||
<UnFilterable
|
||||
paramName={filterKey}
|
||||
searchParams={searchParamsString}
|
||||
name={`${filterKey}: ${searchParams.get(filterKey)}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th></Th>
|
||||
<Th>Camera</Th>
|
||||
<Th>Label</Th>
|
||||
<Th>Score</Th>
|
||||
<Th>Zones</Th>
|
||||
<Th>Date</Th>
|
||||
<Th>Start</Th>
|
||||
<Th>End</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{events.map(
|
||||
(
|
||||
{ camera, id, label, start_time: startTime, end_time: endTime, thumbnail, top_score: score, zones },
|
||||
i
|
||||
) => {
|
||||
const start = new Date(parseInt(startTime * 1000, 10));
|
||||
const end = new Date(parseInt(endTime * 1000, 10));
|
||||
return (
|
||||
<Tr key={id} index={i}>
|
||||
<Td>
|
||||
<a href={`/events/${id}`}>
|
||||
<img className="w-32" src={`data:image/jpeg;base64,${thumbnail}`} />
|
||||
</a>
|
||||
</Td>
|
||||
<Td>
|
||||
<Filterable searchParams={searchParamsString} paramName="camera" name={camera} />
|
||||
</Td>
|
||||
<Td>
|
||||
<Filterable searchParams={searchParamsString} paramName="label" name={label} />
|
||||
</Td>
|
||||
<Td>{(score * 100).toFixed(2)}%</Td>
|
||||
<Td>
|
||||
<ul>
|
||||
{zones.map((zone) => (
|
||||
<li>
|
||||
<Filterable searchParams={searchParamsString} paramName="zone" name={zone} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Td>
|
||||
<Td>{start.toLocaleDateString()}</Td>
|
||||
<Td>{start.toLocaleTimeString()}</Td>
|
||||
<Td>{end.toLocaleTimeString()}</Td>
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Filterable({ searchParams, paramName, name }) {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set(paramName, name);
|
||||
return <Link href={`?${params.toString()}`}>{name}</Link>;
|
||||
}
|
||||
|
||||
function UnFilterable({ searchParams, paramName, name }) {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.delete(paramName);
|
||||
return (
|
||||
<a
|
||||
className="bg-gray-700 text-white px-3 py-1 rounded-md hover:bg-gray-300 hover:text-gray-900 dark:bg-gray-300 dark:text-gray-900 dark:hover:bg-gray-700 dark:hover:text-white"
|
||||
href={`?${params.toString()}`}
|
||||
>
|
||||
{name}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
87
web/src/Sidebar.jsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { h } from 'preact';
|
||||
import Link from './components/Link';
|
||||
import { Link as RouterLink } from 'preact-router/match';
|
||||
import { useCallback, useState } from 'preact/hooks';
|
||||
|
||||
function HamburgerIcon() {
|
||||
return (
|
||||
<svg fill="currentColor" viewBox="0 0 20 20" className="w-6 h-6">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM9 15a1 1 0 011-1h6a1 1 0 110 2h-6a1 1 0 01-1-1z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CloseIcon() {
|
||||
return (
|
||||
<svg fill="currentColor" viewBox="0 0 20 20" className="w-6 h-6">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function NavLink({ className = '', href, text }) {
|
||||
const external = href.startsWith('http');
|
||||
const El = external ? Link : RouterLink;
|
||||
const props = external ? { rel: 'noopener nofollow', target: '_blank' } : {};
|
||||
return (
|
||||
<El
|
||||
activeClassName="bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 dark:focus:bg-gray-600 dark:focus:text-white dark:hover:text-white dark:text-gray-200"
|
||||
className={`block px-4 py-2 mt-2 text-sm font-semibold text-gray-900 bg-transparent rounded-lg dark:bg-transparent dark:hover:bg-gray-600 dark:focus:bg-gray-600 dark:focus:text-white dark:hover:text-white dark:text-gray-200 hover:text-gray-900 focus:text-gray-900 hover:bg-gray-200 focus:bg-gray-200 focus:outline-none focus:shadow-outline self-end ${className}`}
|
||||
href={href}
|
||||
{...props}
|
||||
>
|
||||
{text}
|
||||
</El>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Sidebar() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
setOpen(!open);
|
||||
}, [open, setOpen]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full md:w-64 text-gray-700 bg-white dark:text-gray-200 dark:bg-gray-700 flex-shrink-0">
|
||||
<div className="flex-shrink-0 px-8 py-4 flex flex-row items-center justify-between">
|
||||
<a
|
||||
href="#"
|
||||
className="text-lg font-semibold tracking-widest text-gray-900 uppercase rounded-lg dark:text-white focus:outline-none focus:shadow-outline"
|
||||
>
|
||||
Frigate
|
||||
</a>
|
||||
<button
|
||||
className="rounded-lg md:hidden rounded-lg focus:outline-none focus:shadow-outline"
|
||||
onClick={handleToggle}
|
||||
>
|
||||
{open ? <CloseIcon /> : <HamburgerIcon />}
|
||||
</button>
|
||||
</div>
|
||||
<nav
|
||||
className={`flex-col flex-grow md:block overflow-hidden px-4 pb-4 md:pb-0 md:overflow-y-auto ${
|
||||
!open ? 'md:h-0 hidden' : ''
|
||||
}`}
|
||||
>
|
||||
<NavLink href="/" text="Cameras" />
|
||||
<NavLink href="/events" text="Events" />
|
||||
<NavLink href="/debug" text="Debug" />
|
||||
<hr className="border-solid border-gray-500 mt-2" />
|
||||
<NavLink
|
||||
className="self-end"
|
||||
href="https://github.com/blakeblackshear/frigate/blob/master/README.md"
|
||||
text="Documentation"
|
||||
/>
|
||||
<NavLink className="self-end" href="https://github.com/blakeblackshear/frigate" text="GitHub" />
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
web/src/components/Button.jsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { h } from 'preact';
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
export default function Button({ children, className, color = 'blue', onClick, size, ...attrs }) {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
className={`rounded bg-${color}-500 text-white pl-4 pr-4 pt-2 pb-2 font-bold shadow hover:bg-${color}-400 hover:shadow-lg cursor-pointer ${className}`}
|
||||
onClick={onClick || noop}
|
||||
{...attrs}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
web/src/components/Heading.jsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { h } from 'preact';
|
||||
|
||||
export default function Heading({ children, className = '', size = '2xl' }) {
|
||||
return (
|
||||
<h1 className={`font-semibold tracking-widest text-gray-900 uppercase dark:text-white text-${size} ${className}`}>
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
9
web/src/components/Link.jsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { h } from 'preact';
|
||||
|
||||
export default function Link({ className, children, href, ...props }) {
|
||||
return (
|
||||
<a className={`text-blue-500 hover:underline ${className}`} href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
26
web/src/components/Switch.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { h } from 'preact';
|
||||
import { useCallback, useState } from 'preact/hooks';
|
||||
|
||||
export default function Switch({ checked, label, id, onChange }) {
|
||||
const handleChange = useCallback(
|
||||
(event) => {
|
||||
console.log(event.target.checked, !checked);
|
||||
onChange(id, !checked);
|
||||
},
|
||||
[id, onChange, checked]
|
||||
);
|
||||
|
||||
return (
|
||||
<label for={id} className="flex items-center cursor-pointer">
|
||||
<div className="relative">
|
||||
<input id={id} type="checkbox" className="hidden" onChange={handleChange} checked={checked} />
|
||||
<div className="toggle__line w-12 h-6 bg-gray-400 rounded-full shadow-inner" />
|
||||
<div
|
||||
className="transition-transform absolute w-6 h-6 bg-white rounded-full shadow-md inset-y-0 left-0"
|
||||
style={checked ? 'transform: translateX(100%);' : 'transform: translateX(0%);'}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 text-gray-700 font-medium dark:text-gray-200">{label}</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
29
web/src/components/Table.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { h } from 'preact';
|
||||
|
||||
export function Table({ children }) {
|
||||
return <table className="table-auto border-collapse text-gray-900 dark:text-gray-200">{children}</table>;
|
||||
}
|
||||
|
||||
export function Thead({ children }) {
|
||||
return <thead className="">{children}</thead>;
|
||||
}
|
||||
|
||||
export function Tbody({ children }) {
|
||||
return <tbody className="">{children}</tbody>;
|
||||
}
|
||||
|
||||
export function Tfoot({ children }) {
|
||||
return <tfoot className="">{children}</tfoot>;
|
||||
}
|
||||
|
||||
export function Tr({ children, index }) {
|
||||
return <tr className={`${index % 2 ? 'bg-gray-200 ' : ''}`}>{children}</tr>;
|
||||
}
|
||||
|
||||
export function Th({ children }) {
|
||||
return <th className="border-b-2 border-gray-400 p-4 text-left">{children}</th>;
|
||||
}
|
||||
|
||||
export function Td({ children }) {
|
||||
return <td className="p-4">{children}</td>;
|
||||
}
|
||||
5
web/src/context/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createContext } from 'preact';
|
||||
|
||||
export const Config = createContext({});
|
||||
|
||||
export const ApiHost = createContext(import.meta.env.SNOWPACK_PUBLIC_API_HOST || '');
|
||||
3
web/src/index.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
9
web/src/index.jsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import App from './App';
|
||||
import { h, render } from 'preact';
|
||||
import 'preact/devtools';
|
||||
import './index.css';
|
||||
|
||||
render(
|
||||
<App />,
|
||||
document.getElementById('root')
|
||||
);
|
||||
13
web/tailwind.config.js
Normal file
@@ -0,0 +1,13 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
purge: ['./public/**/*.html', './src/**/*.jsx'],
|
||||
darkMode: 'media',
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
variants: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||