Compare commits

...

50 Commits

Author SHA1 Message Date
Blake Blackshear
91628bd5d8 fix zone config 2021-01-18 06:38:26 -06:00
Blake Blackshear
b10b64bf57 no longer need special aarch64 wheels build 2021-01-17 08:18:54 -06:00
Blake Blackshear
749c34be9f versioning wheels image 2021-01-16 20:03:42 -06:00
Blake Blackshear
8cfdfab985 move wheels to build container 2021-01-16 19:56:21 -06:00
Paul Armstrong
ef25f8a31e fix(web): mask zone editor to handle object filter masks
Includes additional handlers for adding/removing masks, as well as click to copy configs

fixes #523
2021-01-16 19:09:18 -06:00
Paul Armstrong
2a0551a08a feat(web): hash build files to avoid cache issues 2021-01-16 19:09:18 -06:00
Paul Armstrong
0b80419f15 fix(web): ensure mask editing works in firefox 2021-01-16 19:09:18 -06:00
Blake Blackshear
0dc81117aa docs updates for notification changes 2021-01-16 19:09:18 -06:00
Blake Blackshear
49b29d72a7 rename snapshot endpoint to thumbnail 2021-01-16 19:09:18 -06:00
Blake Blackshear
21ece238ff mqtt tweaks for switches 2021-01-16 19:09:18 -06:00
Blake Blackshear
f6ba3f2daa allow summary data to be filtered 2021-01-16 19:09:18 -06:00
Blake Blackshear
bb0d3cb59a update readme 2021-01-16 19:09:18 -06:00
Blake Blackshear
ca9b6d6c5c snapshots config typo 2021-01-16 19:09:18 -06:00
Blake Blackshear
3103ad2bfe update object filters to inherit like motion settings 2021-01-16 19:09:18 -06:00
Blake Blackshear
eab3998ad0 remove support for image masks 2021-01-16 19:09:18 -06:00
Blake Blackshear
a3dfd3a8e0 don't fallback to the CPU
fixes #381
2021-01-16 19:09:18 -06:00
Blake Blackshear
f1c3087775 add change type to events topic
#476
2021-01-16 19:09:18 -06:00
Blake Blackshear
1be91ed3f2 ensure each camera has a detect role set 2021-01-16 19:09:18 -06:00
Blake Blackshear
fd83c4f229 add detection enable to config
fixes #482
2021-01-16 19:09:18 -06:00
Blake Blackshear
de99221ad5 add env vars to config
fixes #509
2021-01-16 19:09:18 -06:00
Blake Blackshear
6892ce56ac enable and disable detection via mqtt 2021-01-16 19:09:18 -06:00
Blake Blackshear
41cea6f62e move setproctitle to prebuilt wheel location 2021-01-16 19:09:18 -06:00
Blake Blackshear
4bbffa97df switch to docker based web builds 2021-01-16 19:09:18 -06:00
Blake Blackshear
614f8abfef handle null thumbnail data 2021-01-16 19:09:18 -06:00
Blake Blackshear
14289b5fd1 add mask as object filter 2021-01-16 19:09:18 -06:00
Blake Blackshear
4164beff1c add object masks and move moton mask 2021-01-16 19:09:18 -06:00
Blake Blackshear
9b3ab486de add missing global shapshots config 2021-01-16 19:09:18 -06:00
Patrick Decat
232a49814a Add missing migrations in docker images 2021-01-16 19:09:18 -06:00
Paul Armstrong
6c61f0b135 fix(web): ensure postcss and postcss-cli are marked as deps 2021-01-16 19:09:18 -06:00
Patrick Decat
c572cec253 Fix Makefile to ignore gpg signatures in commits 2021-01-16 19:09:18 -06:00
Paul Armstrong
d4941f2a5f feat!: web user interface 2021-01-16 19:09:18 -06:00
Blake Blackshear
bf5ec2f65f try to cleanup some migration logging 2021-01-16 19:09:18 -06:00
Blake Blackshear
f8e21584b6 add retention settings for snapshots 2021-01-16 19:09:18 -06:00
Blake Blackshear
3cba83f84b init variables on camera state 2021-01-16 19:09:18 -06:00
Blake Blackshear
dcb4255d7e handle process exit exceptions 2021-01-16 19:09:18 -06:00
Blake Blackshear
9fc3c0dc2f store has_clip and has_snapshot on events 2021-01-16 19:09:18 -06:00
Blake Blackshear
a78830b48e add database migrations 2021-01-16 19:09:18 -06:00
Nat Morris
949fbadcdc Set titles for forked processes 2021-01-16 19:09:18 -06:00
Nat Morris
12c9e63b13 New stats module, refactor stats generation out of http module.
StatsEmitter thread to send stats to MQTT every 60 seconds by default, optional stats_interval config value.

New service stats attribute, containing uptime in seconds and version.
2021-01-16 19:09:18 -06:00
Blake Blackshear
157b230702 turn off snapshots via mqtt 2021-01-16 19:09:18 -06:00
Blake Blackshear
c69299d659 enable turning clips on and off via mqtt 2021-01-16 19:09:18 -06:00
Blake Blackshear
285d630770 cleanup save_Clips/clips inconsistency 2021-01-16 19:09:18 -06:00
Blake Blackshear
b9318092f4 add jpg snapshots to disk and clean up config 2021-01-16 19:09:18 -06:00
Paul Armstrong
905c361d52 fix: ensure timestamp is drawn above mask 2021-01-13 06:55:10 -06:00
Leonardo Merza
4443abbc49 add notes for Blue Iris RTSP support 2020-12-31 08:36:03 -06:00
yllar
dabb36ad93 Update README.md
change tmpfs size from 100MB to 1GB
2020-12-31 08:33:31 -06:00
kluszczyn
2bc8736fd9 Recordings - fix expire_file 2020-12-22 09:58:26 -05:00
Blake Blackshear
e9b3b09cc2 add clips endpoint to readme 2020-12-22 09:58:26 -05:00
Blake Blackshear
ca337c32b4 better mask error handling 2020-12-22 09:58:26 -05:00
Blake Blackshear
24b8bd7c85 fix tmpfs 2020-12-22 09:58:26 -05:00
61 changed files with 10889 additions and 572 deletions

7
.gitignore vendored
View File

@@ -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

View File

@@ -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
View File

@@ -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

View File

@@ -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
View File

@@ -0,0 +1,9 @@
ARG NODE_VERSION=14.0
FROM node:${NODE_VERSION}
WORKDIR /opt/frigate
COPY . .
RUN npm install && npm run build

View File

@@ -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

View File

@@ -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

View File

@@ -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"
```

View File

@@ -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'
```

View File

@@ -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():

File diff suppressed because it is too large Load Diff

View File

@@ -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()

View File

@@ -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()

View File

@@ -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

View File

@@ -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)

View File

@@ -12,3 +12,5 @@ class Event(Model):
false_positive = BooleanField()
zones = JSONField()
thumbnail = TextField()
has_clip = BooleanField(default=True)
has_snapshot = BooleanField(default=True)

View File

@@ -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):

View File

@@ -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

View File

@@ -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

View File

@@ -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
View 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)

View File

@@ -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,

View File

@@ -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:

View File

@@ -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}")

View 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

View 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'])

View File

@@ -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
View File

@@ -0,0 +1 @@
node_modules

8
web/README.md Normal file
View 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

File diff suppressed because it is too large Load Diff

24
web/package.json Normal file
View 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
View File

@@ -0,0 +1,8 @@
'use strict';
module.exports = {
plugins: [
require('tailwindcss'),
require('autoprefixer'),
],
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 B

BIN
web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
web/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

21
web/public/index.html Normal file
View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

9
web/src/index.jsx Normal file
View 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
View File

@@ -0,0 +1,13 @@
'use strict';
module.exports = {
purge: ['./public/**/*.html', './src/**/*.jsx'],
darkMode: 'media',
theme: {
extend: {},
},
variants: {
extend: {},
},
plugins: [],
};