mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-10-04 15:13:22 +08:00
Compare commits
112 Commits
live-view-
...
v0.10.0-be
Author | SHA1 | Date | |
---|---|---|---|
![]() |
889835a59b | ||
![]() |
ee01396b36 | ||
![]() |
334e28fe54 | ||
![]() |
6b2bae040c | ||
![]() |
95ab22d411 | ||
![]() |
4e52461aa9 | ||
![]() |
7934f8699f | ||
![]() |
adbc54bcfe | ||
![]() |
4deb365758 | ||
![]() |
1171770447 | ||
![]() |
54d1a223a5 | ||
![]() |
62c1a61ed0 | ||
![]() |
9ecc7920dd | ||
![]() |
45b56bdce5 | ||
![]() |
54b88fb4a9 | ||
![]() |
a3fa3cb716 | ||
![]() |
64f80a4732 | ||
![]() |
0b02f20b26 | ||
![]() |
8670a3d808 | ||
![]() |
3617a625d3 | ||
![]() |
ad4929c621 | ||
![]() |
9a0d276761 | ||
![]() |
24f9937009 | ||
![]() |
4e23967442 | ||
![]() |
acc1022998 | ||
![]() |
02c91d4c51 | ||
![]() |
5e156f8151 | ||
![]() |
47e0e1d221 | ||
![]() |
f57501d033 | ||
![]() |
1a3f21e5c1 | ||
![]() |
5a2076fcab | ||
![]() |
2d5ec25dca | ||
![]() |
499f75e165 | ||
![]() |
3600ebca39 | ||
![]() |
50b5d40c10 | ||
![]() |
21f1a98da4 | ||
![]() |
21cc29be6f | ||
![]() |
794a9ff162 | ||
![]() |
7b4cb95825 | ||
![]() |
b1e84ca7fe | ||
![]() |
e6ec5cb097 | ||
![]() |
23c70acd51 | ||
![]() |
091648187f | ||
![]() |
2b7d38f947 | ||
![]() |
f801930588 | ||
![]() |
955c2779d9 | ||
![]() |
037f8667a6 | ||
![]() |
307068a61f | ||
![]() |
077d900b44 | ||
![]() |
92f9195075 | ||
![]() |
82c60093d1 | ||
![]() |
944b9181e0 | ||
![]() |
326b368e82 | ||
![]() |
040d8c9778 | ||
![]() |
273f803c7c | ||
![]() |
bd8e23833c | ||
![]() |
9edf38347c | ||
![]() |
1569ce7cf6 | ||
![]() |
db1255aa7f | ||
![]() |
609b436ed8 | ||
![]() |
95bdf9fe34 | ||
![]() |
251d29aa38 | ||
![]() |
156e1a4dc2 | ||
![]() |
a5c13e7455 | ||
![]() |
fcb4aaef0d | ||
![]() |
589432bc89 | ||
![]() |
b19a02888a | ||
![]() |
18fd50dfce | ||
![]() |
df0246aed8 | ||
![]() |
cbb2882123 | ||
![]() |
9f18629df3 | ||
![]() |
63f8034e46 | ||
![]() |
f3efc0667f | ||
![]() |
af001321a8 | ||
![]() |
92e08b92f5 | ||
![]() |
26241b0877 | ||
![]() |
c1155af169 | ||
![]() |
77c1f1bb1b | ||
![]() |
ae3c01fe2d | ||
![]() |
7a2a85d253 | ||
![]() |
77c66d4e49 | ||
![]() |
494e5ac4ec | ||
![]() |
63b7465452 | ||
![]() |
e6d2df5661 | ||
![]() |
a3301e0347 | ||
![]() |
3d556cc2cb | ||
![]() |
585efe1a0f | ||
![]() |
c7d47439dd | ||
![]() |
19a6978228 | ||
![]() |
1ebb8a54bf | ||
![]() |
ae968044d6 | ||
![]() |
b912851e49 | ||
![]() |
14c74e4361 | ||
![]() |
51fb532e1a | ||
![]() |
3541f966e3 | ||
![]() |
c7faef8faa | ||
![]() |
cdd3000315 | ||
![]() |
1c1c28d0e5 | ||
![]() |
4422e86907 | ||
![]() |
8f43a2d109 | ||
![]() |
bd7755fdd3 | ||
![]() |
d554175631 | ||
![]() |
ff667b019a | ||
![]() |
57dcb29f8b | ||
![]() |
9dc6c423b7 | ||
![]() |
58117e2a3e | ||
![]() |
5bec438f9c | ||
![]() |
24cc63d6d3 | ||
![]() |
d17bd74c9a | ||
![]() |
8f101ccca8 | ||
![]() |
b63c56d810 | ||
![]() |
61c62d4685 |
24
.github/workflows/pull_request.yml
vendored
24
.github/workflows/pull_request.yml
vendored
@@ -44,3 +44,27 @@ jobs:
|
||||
- name: Test
|
||||
run: npm run test
|
||||
working-directory: ./web
|
||||
|
||||
docker_tests_on_aarch64:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v2
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
- name: Build and run tests
|
||||
run: make run_tests PLATFORM="linux/arm64/v8" ARCH="aarch64"
|
||||
|
||||
docker_tests_on_amd64:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v2
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
- name: Build and run tests
|
||||
run: make run_tests PLATFORM="linux/amd64" ARCH="amd64"
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,6 +8,7 @@ models
|
||||
*.mp4
|
||||
*.ts
|
||||
*.db
|
||||
*.csv
|
||||
frigate/version.py
|
||||
web/build
|
||||
web/node_modules
|
||||
|
16
Makefile
16
Makefile
@@ -3,7 +3,7 @@ default_target: amd64_frigate
|
||||
COMMIT_HASH := $(shell git log -1 --pretty=format:"%h"|tail -1)
|
||||
|
||||
version:
|
||||
echo "VERSION='0.9.4-$(COMMIT_HASH)'" > frigate/version.py
|
||||
echo "VERSION='0.10.0-$(COMMIT_HASH)'" > frigate/version.py
|
||||
|
||||
web:
|
||||
docker build --tag frigate-web --file docker/Dockerfile.web web/
|
||||
@@ -59,4 +59,16 @@ armv7_frigate: version web
|
||||
|
||||
armv7_all: armv7_wheels armv7_ffmpeg armv7_frigate
|
||||
|
||||
.PHONY: web
|
||||
run_tests:
|
||||
# PLATFORM: linux/arm64/v8 linux/amd64 or linux/arm/v7
|
||||
# ARCH: aarch64 amd64 or armv7
|
||||
@cat docker/Dockerfile.base docker/Dockerfile.$(ARCH) > docker/Dockerfile.test
|
||||
@sed -i "s/FROM frigate-web as web/#/g" docker/Dockerfile.test
|
||||
@sed -i "s/COPY --from=web \/opt\/frigate\/build web\//#/g" docker/Dockerfile.test
|
||||
@sed -i "s/FROM frigate-base/#/g" docker/Dockerfile.test
|
||||
@echo "" >> docker/Dockerfile.test
|
||||
@echo "RUN python3 -m unittest" >> docker/Dockerfile.test
|
||||
@docker buildx build --platform=$(PLATFORM) --tag frigate-base --build-arg NGINX_VERSION=1.0.2 --build-arg FFMPEG_VERSION=1.0.0 --build-arg ARCH=$(ARCH) --build-arg WHEELS_VERSION=1.0.3 --file docker/Dockerfile.test .
|
||||
@rm docker/Dockerfile.test
|
||||
|
||||
.PHONY: web run_tests
|
||||
|
@@ -22,3 +22,5 @@ RUN pip3 install pylint black
|
||||
# Install Node 14
|
||||
RUN curl -sL https://deb.nodesource.com/setup_14.x | bash - \
|
||||
&& apt-get install -y nodejs
|
||||
|
||||
RUN npm install -g npm@latest
|
||||
|
@@ -58,7 +58,7 @@ http {
|
||||
|
||||
# vod caches
|
||||
vod_metadata_cache metadata_cache 512m;
|
||||
vod_mapping_cache mapping_cache 5m;
|
||||
vod_mapping_cache mapping_cache 5m 10m;
|
||||
|
||||
# gzip manifests
|
||||
gzip on;
|
||||
|
@@ -61,8 +61,8 @@ cameras:
|
||||
roles:
|
||||
- detect
|
||||
detect:
|
||||
width: 640
|
||||
height: 480
|
||||
width: 896
|
||||
height: 672
|
||||
fps: 7
|
||||
```
|
||||
|
||||
|
@@ -159,6 +159,11 @@ detect:
|
||||
enabled: True
|
||||
# Optional: Number of frames without a detection before frigate considers an object to be gone. (default: 5x the frame rate)
|
||||
max_disappeared: 25
|
||||
# Optional: Frequency for running detection on stationary objects (default: shown below)
|
||||
# When set to 0, object detection will never be run on stationary objects. If set to 10, it will be run on every 10th frame.
|
||||
stationary_interval: 0
|
||||
# Optional: Number of frames without a position change for an object to be considered stationary (default: 10x the frame rate or 10s)
|
||||
stationary_threshold: 50
|
||||
|
||||
# Optional: Object configuration
|
||||
# NOTE: Can be overridden at the camera level
|
||||
@@ -192,10 +197,14 @@ motion:
|
||||
# Increasing this value will make motion detection less sensitive and decreasing it will make motion detection more sensitive.
|
||||
# The value should be between 1 and 255.
|
||||
threshold: 25
|
||||
# Optional: Minimum size in pixels in the resized motion image that counts as motion (default: ~0.17% of the motion frame area)
|
||||
# Increasing this value will prevent smaller areas of motion from being detected. Decreasing will make motion detection more sensitive to smaller
|
||||
# moving objects.
|
||||
contour_area: 100
|
||||
# Optional: Minimum size in pixels in the resized motion image that counts as motion (default: 30)
|
||||
# Increasing this value will prevent smaller areas of motion from being detected. Decreasing will
|
||||
# make motion detection more sensitive to smaller moving objects.
|
||||
# As a rule of thumb:
|
||||
# - 15 - high sensitivity
|
||||
# - 30 - medium sensitivity
|
||||
# - 50 - low sensitivity
|
||||
contour_area: 30
|
||||
# Optional: Alpha value passed to cv2.accumulateWeighted when averaging the motion delta across multiple frames (default: shown below)
|
||||
# Higher values mean the current frame impacts the delta a lot, and a single raindrop may register as motion.
|
||||
# Too low and a fast moving person wont be detected as motion.
|
||||
@@ -205,10 +214,10 @@ motion:
|
||||
# Low values will cause things like moving shadows to be detected as motion for longer.
|
||||
# https://www.geeksforgeeks.org/background-subtraction-in-an-image-using-concept-of-running-average/
|
||||
frame_alpha: 0.2
|
||||
# Optional: Height of the resized motion frame (default: 1/6th of the original frame height, but no less than 180)
|
||||
# This operates as an efficient blur alternative. Higher values will result in more granular motion detection at the expense of higher CPU usage.
|
||||
# Lower values result in less CPU, but small changes may not register as motion.
|
||||
frame_height: 180
|
||||
# Optional: Height of the resized motion frame (default: 50)
|
||||
# This operates as an efficient blur alternative. Higher values will result in more granular motion detection at the expense
|
||||
# of higher CPU usage. Lower values result in less CPU, but small changes may not register as motion.
|
||||
frame_height: 50
|
||||
# Optional: motion mask
|
||||
# NOTE: see docs for more detailed info on creating masks
|
||||
mask: 0,900,1080,900,1080,1920,0,1920
|
||||
@@ -218,15 +227,26 @@ motion:
|
||||
record:
|
||||
# Optional: Enable recording (default: shown below)
|
||||
enabled: False
|
||||
# Optional: Number of days to retain recordings regardless of events (default: shown below)
|
||||
# NOTE: This should be set to 0 and retention should be defined in events section below
|
||||
# if you only want to retain recordings of events.
|
||||
retain_days: 0
|
||||
# Optional: Number of minutes to wait between cleanup runs (default: shown below)
|
||||
# This can be used to reduce the frequency of deleting recording segments from disk if you want to minimize i/o
|
||||
expire_interval: 60
|
||||
# Optional: Retention settings for recording
|
||||
retain:
|
||||
# Optional: Number of days to retain recordings regardless of events (default: shown below)
|
||||
# NOTE: This should be set to 0 and retention should be defined in events section below
|
||||
# if you only want to retain recordings of events.
|
||||
days: 0
|
||||
# Optional: Mode for retention. Available options are: all, motion, and active_objects
|
||||
# all - save all recording segments regardless of activity
|
||||
# motion - save all recordings segments with any detected motion
|
||||
# active_objects - save all recording segments with active/moving objects
|
||||
# NOTE: this mode only applies when the days setting above is greater than 0
|
||||
mode: all
|
||||
# Optional: Event recording settings
|
||||
events:
|
||||
# Optional: Maximum length of time to retain video during long events. (default: shown below)
|
||||
# NOTE: If an object is being tracked for longer than this amount of time, the retained recordings
|
||||
# will be the last x seconds of the event unless retain_days under record is > 0.
|
||||
# will be the last x seconds of the event unless retain->days under record is > 0.
|
||||
max_seconds: 300
|
||||
# Optional: Number of seconds before the event to include (default: shown below)
|
||||
pre_capture: 5
|
||||
@@ -241,6 +261,16 @@ record:
|
||||
retain:
|
||||
# Required: Default retention days (default: shown below)
|
||||
default: 10
|
||||
# Optional: Mode for retention. (default: shown below)
|
||||
# all - save all recording segments for events regardless of activity
|
||||
# motion - save all recordings segments for events with any detected motion
|
||||
# active_objects - save all recording segments for event with active/moving objects
|
||||
#
|
||||
# NOTE: If the retain mode for the camera is more restrictive than the mode configured
|
||||
# here, the segments will already be gone by the time this mode is applied.
|
||||
# For example, if the camera retain mode is "motion", the segments without motion are
|
||||
# never stored, so setting the mode to "all" here won't bring them back.
|
||||
mode: motion
|
||||
# Optional: Per object retention days
|
||||
objects:
|
||||
person: 15
|
||||
@@ -353,7 +383,7 @@ cameras:
|
||||
# camera.
|
||||
front_steps:
|
||||
# Required: List of x,y coordinates to define the polygon of the zone.
|
||||
# NOTE: Coordinates can be generated at https://www.image-map.net/
|
||||
# NOTE: Presence in a zone is evaluated only based on the bottom center of the objects bounding box.
|
||||
coordinates: 545,1077,747,939,788,805
|
||||
# Optional: List of objects that can trigger this zone (default: all tracked objects)
|
||||
objects:
|
||||
|
@@ -97,15 +97,3 @@ processes:
|
||||
| 0 N/A N/A 12827 C ffmpeg 417MiB |
|
||||
+-----------------------------------------------------------------------------+
|
||||
```
|
||||
|
||||
To further improve performance, you can set ffmpeg to skip frames in the output,
|
||||
using the fps filter:
|
||||
|
||||
```yaml
|
||||
output_args:
|
||||
- -filter:v
|
||||
- fps=fps=5
|
||||
```
|
||||
|
||||
This setting, for example, allows Frigate to consume my 10-15fps camera streams on
|
||||
my relatively low powered Haswell machine with relatively low cpu usage.
|
||||
|
@@ -14,12 +14,11 @@ If you only used clips in previous versions with recordings disabled, you can us
|
||||
```yaml
|
||||
record:
|
||||
enabled: True
|
||||
retain_days: 0
|
||||
events:
|
||||
retain:
|
||||
default: 10
|
||||
```
|
||||
|
||||
This configuration will retain recording segments that overlap with events for 10 days. Because multiple events can reference the same recording segments, this avoids storing duplicate footage for overlapping events and reduces overall storage needs.
|
||||
This configuration will retain recording segments that overlap with events and have active tracked objects for 10 days. Because multiple events can reference the same recording segments, this avoids storing duplicate footage for overlapping events and reduces overall storage needs.
|
||||
|
||||
When `retain_days` is set to `0`, events will have up to `max_seconds` (defaults to 5 minutes) of recordings retained. Increasing `retain_days` to `1` will allow events to exceed the `max_seconds` limitation of up to 1 day.
|
||||
When `retain_days` is set to `0`, segments will be deleted from the cache if no events are in progress.
|
||||
|
@@ -3,7 +3,9 @@ id: zones
|
||||
title: Zones
|
||||
---
|
||||
|
||||
Zones allow you to define a specific area of the frame and apply additional filters for object types so you can determine whether or not an object is within a particular area. Zones cannot have the same name as a camera. If desired, a single zone can include multiple cameras if you have multiple cameras covering the same area by configuring zones with the same name for each camera.
|
||||
Zones allow you to define a specific area of the frame and apply additional filters for object types so you can determine whether or not an object is within a particular area. Presence in a zone is evaluated based on the bottom center of the bounding box for the object. It does not matter how much of the bounding box overlaps with the zone.
|
||||
|
||||
Zones cannot have the same name as a camera. If desired, a single zone can include multiple cameras if you have multiple cameras covering the same area by configuring zones with the same name for each camera.
|
||||
|
||||
During testing, enable the Zones option for the debug feed so you can adjust as needed. The zone line will increase in thickness when any object enters the zone.
|
||||
|
||||
|
@@ -62,6 +62,8 @@ cameras:
|
||||
roles:
|
||||
- detect
|
||||
- rtmp
|
||||
rtmp:
|
||||
enabled: False # <-- RTMP should be disabled if your stream is not H264
|
||||
detect:
|
||||
width: 1280 # <---- update for your camera's resolution
|
||||
height: 720 # <---- update for your camera's resolution
|
||||
@@ -71,7 +73,9 @@ cameras:
|
||||
|
||||
At this point you should be able to start Frigate and see the the video feed in the UI.
|
||||
|
||||
If you get a green image from the camera, this means ffmpeg was not able to get the video feed from your camera. Check the logs for error messages from ffmpeg. The default ffmpeg arguments are designed to work with RTSP cameras that support TCP connections. FFmpeg arguments for other types of cameras can be found [here](/configuration/camera_specific).
|
||||
If you get a green image from the camera, this means ffmpeg was not able to get the video feed from your camera. Check the logs for error messages from ffmpeg. The default ffmpeg arguments are designed to work with H264 RTSP cameras that support TCP connections. If you do not have H264 cameras, make sure you have disabled RTMP. It is possible to enable it, but you must tell ffmpeg to re-encode the video with customized output args.
|
||||
|
||||
FFmpeg arguments for other types of cameras can be found [here](/configuration/camera_specific).
|
||||
|
||||
### Step 5: Configure hardware acceleration (optional)
|
||||
|
||||
@@ -163,13 +167,17 @@ cameras:
|
||||
roles:
|
||||
- detect
|
||||
- rtmp
|
||||
- record # <----- Add role
|
||||
- path: rtsp://10.0.10.10:554/high_res_stream # <----- Add high res stream
|
||||
roles:
|
||||
- record
|
||||
detect: ...
|
||||
record: # <----- Enable recording
|
||||
enabled: True
|
||||
motion: ...
|
||||
```
|
||||
|
||||
If you don't have separate streams for detect and record, you would just add the record role to the list on the first input.
|
||||
|
||||
By default, Frigate will retain video of all events for 10 days. The full set of options for recording can be found [here](/configuration/index#full-configuration-reference).
|
||||
|
||||
### Step 8: Enable snapshots (optional)
|
||||
|
@@ -25,6 +25,30 @@ automation:
|
||||
when: '{{trigger.payload_json["after"]["start_time"]|int}}'
|
||||
```
|
||||
|
||||
Note that iOS devices support live previews of cameras by adding a camera entity id to the message data.
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
- alias: Security_Frigate_Notifications
|
||||
description: ""
|
||||
trigger:
|
||||
- platform: mqtt
|
||||
topic: frigate/events
|
||||
payload: new
|
||||
value_template: "{{ value_json.type }}"
|
||||
action:
|
||||
- service: notify.mobile_app_iphone
|
||||
data:
|
||||
message: 'A {{trigger.payload_json["after"]["label"]}} was detected.'
|
||||
data:
|
||||
image: >-
|
||||
https://your.public.hass.address.com/api/frigate/notifications/{{trigger.payload_json["after"]["id"]}}/thumbnail.jpg
|
||||
tag: '{{trigger.payload_json["after"]["id"]}}'
|
||||
when: '{{trigger.payload_json["after"]["start_time"]|int}}'
|
||||
entity_id: camera.{{trigger.payload_json["after"]["camera"]}}
|
||||
mode: single
|
||||
```
|
||||
|
||||
## Conditions
|
||||
|
||||
Conditions with the `before` and `after` values allow a high degree of customization for automations.
|
||||
|
@@ -78,7 +78,7 @@ Frigate utilizes shared memory to store frames during processing. The default `s
|
||||
|
||||
The default shm-size of 64m is fine for setups with 2 or less 1080p cameras. If frigate is exiting with "Bus error" messages, it is likely because you have too many high resolution cameras and you need to specify a higher shm size.
|
||||
|
||||
You can calculate the necessary shm-size for each camera with the following formula:
|
||||
You can calculate the necessary shm-size for each camera with the following formula using the resolution specified for detect:
|
||||
|
||||
```
|
||||
(width * height * 1.5 * 9 + 270480)/1048576 = <shm size in mb>
|
||||
@@ -177,6 +177,15 @@ HassOS users can install via the addon repository.
|
||||
6. Start the addon container
|
||||
7. (not for proxy addon) If you are using hardware acceleration for ffmpeg, you may need to disable "Protection mode"
|
||||
|
||||
There are several versions of the addon available:
|
||||
|
||||
| Addon Version | Description |
|
||||
| ------------------------------ | ---------------------------------------------------------- |
|
||||
| Frigate NVR | Current release with protection mode on |
|
||||
| Frigate NVR (Full Access) | Current release with the option to disable protection mode |
|
||||
| Frigate NVR Beta | Beta release with protection mode on |
|
||||
| Frigate NVR Beta (Full Access) | Beta release with the option to disable protection mode |
|
||||
|
||||
## Home Assistant Supervised
|
||||
|
||||
:::tip
|
||||
|
@@ -45,11 +45,14 @@ that card.
|
||||
|
||||
## Configuration
|
||||
|
||||
When configuring the integration, you will be asked for the following parameters:
|
||||
When configuring the integration, you will be asked for the `URL` of your frigate instance which is the URL you use to access Frigate in the browser. This may look like `http://<host>:5000/`. If you are using HassOS with the addon, the URL should be one of the following depending on which addon version you are using. Note that if you are using the Proxy Addon, you do NOT point the integration at the proxy URL. Just enter the URL used to access frigate directly from your network.
|
||||
|
||||
| Variable | Description |
|
||||
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| URL | The `URL` of your frigate instance, the URL you use to access Frigate in the browser. This may look like `http://<host>:5000/`. If you are using HassOS with the addon, the URL should be `http://ccab4aaf-frigate:5000` (or `http://ccab4aaf-frigate-beta:5000` if your are using the beta version of the addon). Live streams required port 1935, see [RTMP streams](#streams) |
|
||||
| Addon Version | URL |
|
||||
| ------------------------------ | -------------------------------------- |
|
||||
| Frigate NVR | `http://ccab4aaf-frigate:5000` |
|
||||
| Frigate NVR (Full Access) | `http://ccab4aaf-frigate-fa:5000` |
|
||||
| Frigate NVR Beta | `http://ccab4aaf-frigate-beta:5000` |
|
||||
| Frigate NVR Beta (Full Access) | `http://ccab4aaf-frigate-fa-beta:5000` |
|
||||
|
||||
<a name="options"></a>
|
||||
|
||||
|
@@ -55,7 +55,10 @@ Message published for each changed event. The first message is published when th
|
||||
"entered_zones": ["yard", "driveway"],
|
||||
"thumbnail": null,
|
||||
"has_snapshot": false,
|
||||
"has_clip": false
|
||||
"has_clip": false,
|
||||
"stationary": false, // whether or not the object is considered stationary
|
||||
"motionless_count": 0, // number of frames the object has been motionless
|
||||
"position_changes": 2 // number of times the object has moved from a stationary position
|
||||
},
|
||||
"after": {
|
||||
"id": "1607123955.475377-mxklsc",
|
||||
@@ -75,7 +78,10 @@ Message published for each changed event. The first message is published when th
|
||||
"entered_zones": ["yard", "driveway"],
|
||||
"thumbnail": null,
|
||||
"has_snapshot": false,
|
||||
"has_clip": false
|
||||
"has_clip": false,
|
||||
"stationary": false, // whether or not the object is considered stationary
|
||||
"motionless_count": 0, // number of frames the object has been motionless
|
||||
"position_changes": 2 // number of times the object has changed position
|
||||
}
|
||||
}
|
||||
```
|
||||
|
14859
docs/package-lock.json
generated
14859
docs/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,13 +12,13 @@
|
||||
"clear": "docusaurus clear"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "^2.0.0-beta.6",
|
||||
"@docusaurus/preset-classic": "^2.0.0-beta.6",
|
||||
"@mdx-js/react": "^1.6.21",
|
||||
"@docusaurus/core": "^2.0.0-beta.15",
|
||||
"@docusaurus/preset-classic": "^2.0.0-beta.15",
|
||||
"@mdx-js/react": "^1.6.22",
|
||||
"clsx": "^1.1.1",
|
||||
"raw-loader": "^4.0.2",
|
||||
"react": "^16.8.4",
|
||||
"react-dom": "^16.8.4"
|
||||
"react": "^16.14.0",
|
||||
"react-dom": "^16.14.0"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
@@ -31,5 +31,8 @@
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^16.14.0"
|
||||
}
|
||||
}
|
||||
|
@@ -8,6 +8,7 @@ import threading
|
||||
from logging.handlers import QueueHandler
|
||||
from typing import Dict, List
|
||||
|
||||
import traceback
|
||||
import yaml
|
||||
from peewee_migrate import Router
|
||||
from playhouse.sqlite_ext import SqliteExtDatabase
|
||||
@@ -67,6 +68,12 @@ class FrigateApp:
|
||||
|
||||
def init_config(self):
|
||||
config_file = os.environ.get("CONFIG_FILE", "/config/config.yml")
|
||||
|
||||
# Check if we can use .yaml instead of .yml
|
||||
config_file_yaml = config_file.replace(".yml", ".yaml")
|
||||
if os.path.isfile(config_file_yaml):
|
||||
config_file = config_file_yaml
|
||||
|
||||
user_config = FrigateConfig.parse_file(config_file)
|
||||
self.config = user_config.runtime_config
|
||||
|
||||
@@ -105,6 +112,9 @@ class FrigateApp:
|
||||
maxsize=len(self.config.cameras.keys()) * 2
|
||||
)
|
||||
|
||||
# Queue for recordings info
|
||||
self.recordings_info_queue = mp.Queue()
|
||||
|
||||
def init_database(self):
|
||||
# Migrate DB location
|
||||
old_db_path = os.path.join(CLIPS_DIR, "frigate.db")
|
||||
@@ -203,6 +213,7 @@ class FrigateApp:
|
||||
self.event_queue,
|
||||
self.event_processed_queue,
|
||||
self.video_output_queue,
|
||||
self.recordings_info_queue,
|
||||
self.stop_event,
|
||||
)
|
||||
self.detected_frames_processor.start()
|
||||
@@ -270,7 +281,9 @@ class FrigateApp:
|
||||
self.event_cleanup.start()
|
||||
|
||||
def start_recording_maintainer(self):
|
||||
self.recording_maintainer = RecordingMaintainer(self.config, self.stop_event)
|
||||
self.recording_maintainer = RecordingMaintainer(
|
||||
self.config, self.recordings_info_queue, self.stop_event
|
||||
)
|
||||
self.recording_maintainer.start()
|
||||
|
||||
def start_recording_cleanup(self):
|
||||
@@ -308,6 +321,7 @@ class FrigateApp:
|
||||
print("*** Config Validation Errors ***")
|
||||
print("*************************************************************")
|
||||
print(e)
|
||||
print(traceback.format_exc())
|
||||
print("*************************************************************")
|
||||
print("*** End Config Validation Errors ***")
|
||||
print("*************************************************************")
|
||||
|
@@ -12,9 +12,8 @@ import yaml
|
||||
from pydantic import BaseModel, Extra, Field, validator
|
||||
from pydantic.fields import PrivateAttr
|
||||
|
||||
from frigate.const import BASE_DIR, CACHE_DIR, RECORD_DIR
|
||||
from frigate.edgetpu import load_labels
|
||||
from frigate.util import create_mask, deep_merge
|
||||
from frigate.const import BASE_DIR, CACHE_DIR, YAML_EXT
|
||||
from frigate.util import create_mask, deep_merge, load_labels
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -65,8 +64,15 @@ class MqttConfig(FrigateBaseModel):
|
||||
return v
|
||||
|
||||
|
||||
class RetainModeEnum(str, Enum):
|
||||
all = "all"
|
||||
motion = "motion"
|
||||
active_objects = "active_objects"
|
||||
|
||||
|
||||
class RetainConfig(FrigateBaseModel):
|
||||
default: float = Field(default=10, title="Default retention period.")
|
||||
mode: RetainModeEnum = Field(default=RetainModeEnum.motion, title="Retain mode.")
|
||||
objects: Dict[str, float] = Field(
|
||||
default_factory=dict, title="Object retention period."
|
||||
)
|
||||
@@ -88,9 +94,22 @@ class EventsConfig(FrigateBaseModel):
|
||||
)
|
||||
|
||||
|
||||
class RecordRetainConfig(FrigateBaseModel):
|
||||
days: float = Field(default=0, title="Default retention period.")
|
||||
mode: RetainModeEnum = Field(default=RetainModeEnum.all, title="Retain mode.")
|
||||
|
||||
|
||||
class RecordConfig(FrigateBaseModel):
|
||||
enabled: bool = Field(default=False, title="Enable record on all cameras.")
|
||||
retain_days: float = Field(default=0, title="Recording retention period in days.")
|
||||
expire_interval: int = Field(
|
||||
default=60,
|
||||
title="Number of minutes to wait between cleanup runs.",
|
||||
)
|
||||
# deprecated - to be removed in a future version
|
||||
retain_days: Optional[float] = Field(title="Recording retention period in days.")
|
||||
retain: RecordRetainConfig = Field(
|
||||
default_factory=RecordRetainConfig, title="Record retention settings."
|
||||
)
|
||||
events: EventsConfig = Field(
|
||||
default_factory=EventsConfig, title="Event specific settings."
|
||||
)
|
||||
@@ -103,10 +122,10 @@ class MotionConfig(FrigateBaseModel):
|
||||
ge=1,
|
||||
le=255,
|
||||
)
|
||||
contour_area: Optional[int] = Field(title="Contour Area")
|
||||
contour_area: Optional[int] = Field(default=30, title="Contour Area")
|
||||
delta_alpha: float = Field(default=0.2, title="Delta Alpha")
|
||||
frame_alpha: float = Field(default=0.2, title="Frame Alpha")
|
||||
frame_height: Optional[int] = Field(title="Frame Height")
|
||||
frame_height: Optional[int] = Field(default=50, title="Frame Height")
|
||||
mask: Union[str, List[str]] = Field(
|
||||
default="", title="Coordinates polygon for the motion mask."
|
||||
)
|
||||
@@ -119,15 +138,6 @@ class RuntimeMotionConfig(MotionConfig):
|
||||
def __init__(self, **config):
|
||||
frame_shape = config.get("frame_shape", (1, 1))
|
||||
|
||||
if "frame_height" not in config:
|
||||
config["frame_height"] = max(frame_shape[0] // 6, 180)
|
||||
|
||||
if "contour_area" not in config:
|
||||
frame_width = frame_shape[1] * config["frame_height"] / frame_shape[0]
|
||||
config["contour_area"] = (
|
||||
config["frame_height"] * frame_width * 0.00173611111
|
||||
)
|
||||
|
||||
mask = config.get("mask", "")
|
||||
config["raw_mask"] = mask
|
||||
|
||||
@@ -162,6 +172,15 @@ class DetectConfig(FrigateBaseModel):
|
||||
max_disappeared: Optional[int] = Field(
|
||||
title="Maximum number of frames the object can dissapear before detection ends."
|
||||
)
|
||||
stationary_interval: Optional[int] = Field(
|
||||
default=0,
|
||||
title="Frame interval for checking stationary objects.",
|
||||
ge=0,
|
||||
)
|
||||
stationary_threshold: Optional[int] = Field(
|
||||
title="Number of frames without a position change for an object to be considered stationary",
|
||||
ge=1,
|
||||
)
|
||||
|
||||
|
||||
class FilterConfig(FrigateBaseModel):
|
||||
@@ -461,7 +480,7 @@ class CameraLiveConfig(FrigateBaseModel):
|
||||
|
||||
|
||||
class CameraConfig(FrigateBaseModel):
|
||||
name: Optional[str] = Field(title="Camera name.")
|
||||
name: Optional[str] = Field(title="Camera name.", regex="^[a-zA-Z0-9_-]+$")
|
||||
ffmpeg: CameraFfmpegConfig = Field(title="FFmpeg configuration for the camera.")
|
||||
best_image_timeout: int = Field(
|
||||
default=60,
|
||||
@@ -495,6 +514,7 @@ class CameraConfig(FrigateBaseModel):
|
||||
timestamp_style: TimestampStyleConfig = Field(
|
||||
default_factory=TimestampStyleConfig, title="Timestamp style configuration."
|
||||
)
|
||||
_ffmpeg_cmds: List[Dict[str, List[str]]] = PrivateAttr()
|
||||
|
||||
def __init__(self, **config):
|
||||
# Set zone colors
|
||||
@@ -521,6 +541,11 @@ class CameraConfig(FrigateBaseModel):
|
||||
|
||||
@property
|
||||
def ffmpeg_cmds(self) -> List[Dict[str, List[str]]]:
|
||||
return self._ffmpeg_cmds
|
||||
|
||||
def create_ffmpeg_cmds(self):
|
||||
if "_ffmpeg_cmds" in self:
|
||||
return
|
||||
ffmpeg_cmds = []
|
||||
for ffmpeg_input in self.ffmpeg.inputs:
|
||||
ffmpeg_cmd = self._get_ffmpeg_cmd(ffmpeg_input)
|
||||
@@ -528,7 +553,7 @@ class CameraConfig(FrigateBaseModel):
|
||||
continue
|
||||
|
||||
ffmpeg_cmds.append({"roles": ffmpeg_input.roles, "cmd": ffmpeg_cmd})
|
||||
return ffmpeg_cmds
|
||||
self._ffmpeg_cmds = ffmpeg_cmds
|
||||
|
||||
def _get_ffmpeg_cmd(self, ffmpeg_input: CameraInput):
|
||||
ffmpeg_output_args = []
|
||||
@@ -623,7 +648,7 @@ class ModelConfig(FrigateBaseModel):
|
||||
return self._merged_labelmap
|
||||
|
||||
@property
|
||||
def colormap(self) -> Dict[int, tuple[int, int, int]]:
|
||||
def colormap(self) -> Dict[int, Tuple[int, int, int]]:
|
||||
return self._colormap
|
||||
|
||||
def __init__(self, **config):
|
||||
@@ -745,6 +770,11 @@ class FrigateConfig(FrigateBaseModel):
|
||||
if camera_config.detect.max_disappeared is None:
|
||||
camera_config.detect.max_disappeared = max_disappeared
|
||||
|
||||
# Default stationary_threshold configuration
|
||||
stationary_threshold = camera_config.detect.fps * 10
|
||||
if camera_config.detect.stationary_threshold is None:
|
||||
camera_config.detect.stationary_threshold = stationary_threshold
|
||||
|
||||
# FFMPEG input substitution
|
||||
for input in camera_config.ffmpeg.inputs:
|
||||
input.path = input.path.format(**FRIGATE_ENV_VARS)
|
||||
@@ -806,6 +836,30 @@ class FrigateConfig(FrigateBaseModel):
|
||||
f"Camera {name} has rtmp enabled, but rtmp is not assigned to an input."
|
||||
)
|
||||
|
||||
# backwards compatibility for retain_days
|
||||
if not camera_config.record.retain_days is None:
|
||||
logger.warning(
|
||||
"The 'retain_days' config option has been DEPRECATED and will be removed in a future version. Please use the 'days' setting under 'retain'"
|
||||
)
|
||||
if camera_config.record.retain.days == 0:
|
||||
camera_config.record.retain.days = camera_config.record.retain_days
|
||||
|
||||
# warning if the higher level record mode is potentially more restrictive than the events
|
||||
rank_map = {
|
||||
RetainModeEnum.all: 0,
|
||||
RetainModeEnum.motion: 1,
|
||||
RetainModeEnum.active_objects: 2,
|
||||
}
|
||||
if (
|
||||
camera_config.record.retain.days != 0
|
||||
and rank_map[camera_config.record.retain.mode]
|
||||
> rank_map[camera_config.record.events.retain.mode]
|
||||
):
|
||||
logger.warning(
|
||||
f"{name}: Recording retention is configured for {camera_config.record.retain.mode} and event retention is configured for {camera_config.record.events.retain.mode}. The more restrictive retention policy will be applied."
|
||||
)
|
||||
# generage the ffmpeg commands
|
||||
camera_config.create_ffmpeg_cmds()
|
||||
config.cameras[name] = camera_config
|
||||
|
||||
return config
|
||||
@@ -823,7 +877,7 @@ class FrigateConfig(FrigateBaseModel):
|
||||
with open(config_file) as f:
|
||||
raw_config = f.read()
|
||||
|
||||
if config_file.endswith(".yml"):
|
||||
if config_file.endswith(YAML_EXT):
|
||||
config = yaml.safe_load(raw_config)
|
||||
elif config_file.endswith(".json"):
|
||||
config = json.loads(raw_config)
|
||||
|
@@ -2,3 +2,4 @@ BASE_DIR = "/media/frigate"
|
||||
CLIPS_DIR = f"{BASE_DIR}/clips"
|
||||
RECORD_DIR = f"{BASE_DIR}/recordings"
|
||||
CACHE_DIR = "/tmp/cache"
|
||||
YAML_EXT = (".yaml", ".yml")
|
||||
|
@@ -13,31 +13,11 @@ import tflite_runtime.interpreter as tflite
|
||||
from setproctitle import setproctitle
|
||||
from tflite_runtime.interpreter import load_delegate
|
||||
|
||||
from frigate.util import EventsPerSecond, SharedMemoryFrameManager, listen
|
||||
from frigate.util import EventsPerSecond, SharedMemoryFrameManager, listen, load_labels
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def load_labels(path, encoding="utf-8"):
|
||||
"""Loads labels from file (with or without index numbers).
|
||||
Args:
|
||||
path: path to label file.
|
||||
encoding: label file encoding.
|
||||
Returns:
|
||||
Dictionary mapping indices to labels.
|
||||
"""
|
||||
with open(path, "r", encoding=encoding) as f:
|
||||
lines = f.readlines()
|
||||
if not lines:
|
||||
return {}
|
||||
|
||||
if lines[0].split(" ", maxsplit=1)[0].isdigit():
|
||||
pairs = [line.split(" ", maxsplit=1) for line in lines]
|
||||
return {int(index): label.strip() for index, label in pairs}
|
||||
else:
|
||||
return {index: line.strip() for index, line in enumerate(lines)}
|
||||
|
||||
|
||||
class ObjectDetector(ABC):
|
||||
@abstractmethod
|
||||
def detect(self, tensor_input, threshold=0.4):
|
||||
|
@@ -15,6 +15,16 @@ from frigate.models import Event
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def should_update_db(prev_event, current_event):
|
||||
return (
|
||||
prev_event["top_score"] != current_event["top_score"]
|
||||
or prev_event["entered_zones"] != current_event["entered_zones"]
|
||||
or prev_event["thumbnail"] != current_event["thumbnail"]
|
||||
or prev_event["has_clip"] != current_event["has_clip"]
|
||||
or prev_event["has_snapshot"] != current_event["has_snapshot"]
|
||||
)
|
||||
|
||||
|
||||
class EventProcessor(threading.Thread):
|
||||
def __init__(
|
||||
self, config, camera_processes, event_queue, event_processed_queue, stop_event
|
||||
@@ -30,6 +40,11 @@ class EventProcessor(threading.Thread):
|
||||
self.stop_event = stop_event
|
||||
|
||||
def run(self):
|
||||
# set an end_time on events without an end_time on startup
|
||||
Event.update(end_time=Event.start_time + 30).where(
|
||||
Event.end_time == None
|
||||
).execute()
|
||||
|
||||
while not self.stop_event.is_set():
|
||||
try:
|
||||
event_type, camera, event_data = self.event_queue.get(timeout=10)
|
||||
@@ -38,14 +53,37 @@ class EventProcessor(threading.Thread):
|
||||
|
||||
logger.debug(f"Event received: {event_type} {camera} {event_data['id']}")
|
||||
|
||||
event_config: EventsConfig = self.config.cameras[camera].record.events
|
||||
|
||||
if event_type == "start":
|
||||
self.events_in_process[event_data["id"]] = event_data
|
||||
|
||||
if event_type == "end":
|
||||
event_config: EventsConfig = self.config.cameras[camera].record.events
|
||||
|
||||
elif event_type == "update" and should_update_db(
|
||||
self.events_in_process[event_data["id"]], event_data
|
||||
):
|
||||
self.events_in_process[event_data["id"]] = event_data
|
||||
# TODO: this will generate a lot of db activity possibly
|
||||
if event_data["has_clip"] or event_data["has_snapshot"]:
|
||||
Event.create(
|
||||
Event.replace(
|
||||
id=event_data["id"],
|
||||
label=event_data["label"],
|
||||
camera=camera,
|
||||
start_time=event_data["start_time"] - event_config.pre_capture,
|
||||
end_time=None,
|
||||
top_score=event_data["top_score"],
|
||||
false_positive=event_data["false_positive"],
|
||||
zones=list(event_data["entered_zones"]),
|
||||
thumbnail=event_data["thumbnail"],
|
||||
region=event_data["region"],
|
||||
box=event_data["box"],
|
||||
area=event_data["area"],
|
||||
has_clip=event_data["has_clip"],
|
||||
has_snapshot=event_data["has_snapshot"],
|
||||
).execute()
|
||||
|
||||
elif event_type == "end":
|
||||
if event_data["has_clip"] or event_data["has_snapshot"]:
|
||||
Event.replace(
|
||||
id=event_data["id"],
|
||||
label=event_data["label"],
|
||||
camera=camera,
|
||||
@@ -60,11 +98,15 @@ class EventProcessor(threading.Thread):
|
||||
area=event_data["area"],
|
||||
has_clip=event_data["has_clip"],
|
||||
has_snapshot=event_data["has_snapshot"],
|
||||
)
|
||||
).execute()
|
||||
|
||||
del self.events_in_process[event_data["id"]]
|
||||
self.event_processed_queue.put((event_data["id"], camera))
|
||||
|
||||
# set an end_time on events without an end_time before exiting
|
||||
Event.update(end_time=datetime.datetime.now().timestamp()).where(
|
||||
Event.end_time == None
|
||||
).execute()
|
||||
logger.info(f"Exiting event processor...")
|
||||
|
||||
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import base64
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime, timedelta
|
||||
import copy
|
||||
import json
|
||||
import glob
|
||||
import logging
|
||||
@@ -132,6 +133,8 @@ def delete_event(id):
|
||||
if event.has_snapshot:
|
||||
media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg")
|
||||
media.unlink(missing_ok=True)
|
||||
media = Path(f"{os.path.join(CLIPS_DIR, media_name)}-clean.png")
|
||||
media.unlink(missing_ok=True)
|
||||
if event.has_clip:
|
||||
media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.mp4")
|
||||
media.unlink(missing_ok=True)
|
||||
@@ -181,7 +184,7 @@ def event_thumbnail(id):
|
||||
thumbnail_bytes = jpg.tobytes()
|
||||
|
||||
response = make_response(thumbnail_bytes)
|
||||
response.headers["Content-Type"] = "image/jpg"
|
||||
response.headers["Content-Type"] = "image/jpeg"
|
||||
return response
|
||||
|
||||
|
||||
@@ -190,7 +193,7 @@ def event_snapshot(id):
|
||||
download = request.args.get("download", type=bool)
|
||||
jpg_bytes = None
|
||||
try:
|
||||
event = Event.get(Event.id == id)
|
||||
event = Event.get(Event.id == id, Event.end_time != None)
|
||||
if not event.has_snapshot:
|
||||
return "Snapshot not available", 404
|
||||
# read snapshot from disk
|
||||
@@ -222,7 +225,7 @@ def event_snapshot(id):
|
||||
return "Event not found", 404
|
||||
|
||||
response = make_response(jpg_bytes)
|
||||
response.headers["Content-Type"] = "image/jpg"
|
||||
response.headers["Content-Type"] = "image/jpeg"
|
||||
if download:
|
||||
response.headers[
|
||||
"Content-Disposition"
|
||||
@@ -246,7 +249,10 @@ def event_clip(id):
|
||||
clip_path = os.path.join(CLIPS_DIR, file_name)
|
||||
|
||||
if not os.path.isfile(clip_path):
|
||||
return recording_clip(event.camera, event.start_time, event.end_time)
|
||||
end_ts = (
|
||||
datetime.now().timestamp() if event.end_time is None else event.end_time
|
||||
)
|
||||
return recording_clip(event.camera, event.start_time, end_ts)
|
||||
|
||||
response = make_response()
|
||||
response.headers["Content-Description"] = "File Transfer"
|
||||
@@ -321,7 +327,7 @@ def config():
|
||||
# add in the ffmpeg_cmds
|
||||
for camera_name, camera in current_app.frigate_config.cameras.items():
|
||||
camera_dict = config["cameras"][camera_name]
|
||||
camera_dict["ffmpeg_cmds"] = camera.ffmpeg_cmds
|
||||
camera_dict["ffmpeg_cmds"] = copy.deepcopy(camera.ffmpeg_cmds)
|
||||
for cmd in camera_dict["ffmpeg_cmds"]:
|
||||
cmd["cmd"] = " ".join(cmd["cmd"])
|
||||
|
||||
@@ -358,9 +364,16 @@ def best(camera_name, label):
|
||||
|
||||
crop = bool(request.args.get("crop", 0, type=int))
|
||||
if crop:
|
||||
box = best_object.get("box", (0, 0, 300, 300))
|
||||
box_size = 300
|
||||
box = best_object.get("box", (0, 0, box_size, box_size))
|
||||
region = calculate_region(
|
||||
best_frame.shape, box[0], box[1], box[2], box[3], 1.1
|
||||
best_frame.shape,
|
||||
box[0],
|
||||
box[1],
|
||||
box[2],
|
||||
box[3],
|
||||
box_size,
|
||||
multiplier=1.1,
|
||||
)
|
||||
best_frame = best_frame[region[1] : region[3], region[0] : region[2]]
|
||||
|
||||
@@ -375,7 +388,7 @@ def best(camera_name, label):
|
||||
".jpg", best_frame, [int(cv2.IMWRITE_JPEG_QUALITY), resize_quality]
|
||||
)
|
||||
response = make_response(jpg.tobytes())
|
||||
response.headers["Content-Type"] = "image/jpg"
|
||||
response.headers["Content-Type"] = "image/jpeg"
|
||||
return response
|
||||
else:
|
||||
return "Camera named {} not found".format(camera_name), 404
|
||||
@@ -437,7 +450,7 @@ def latest_frame(camera_name):
|
||||
".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), resize_quality]
|
||||
)
|
||||
response = make_response(jpg.tobytes())
|
||||
response.headers["Content-Type"] = "image/jpg"
|
||||
response.headers["Content-Type"] = "image/jpeg"
|
||||
return response
|
||||
else:
|
||||
return "Camera named {} not found".format(camera_name), 404
|
||||
@@ -514,12 +527,17 @@ def recordings(camera_name):
|
||||
FROM C2
|
||||
WHERE cnt = 0
|
||||
)
|
||||
SELECT id, label, camera, top_score, start_time, end_time
|
||||
FROM event
|
||||
WHERE camera = ? AND end_time IS NULL
|
||||
UNION ALL
|
||||
SELECT MIN(id) as id, label, camera, MAX(top_score) as top_score, MIN(ts) AS start_time, max(ts) AS end_time
|
||||
FROM C3
|
||||
GROUP BY label, grpnum
|
||||
ORDER BY start_time;""",
|
||||
camera_name,
|
||||
camera_name,
|
||||
camera_name,
|
||||
)
|
||||
|
||||
event: Event
|
||||
@@ -657,10 +675,15 @@ def vod_ts(camera, start_ts, end_ts):
|
||||
# Determine if we need to end the last clip early
|
||||
if recording.end_time > end_ts:
|
||||
duration -= int((recording.end_time - end_ts) * 1000)
|
||||
clips.append(clip)
|
||||
durations.append(duration)
|
||||
|
||||
if duration > 0:
|
||||
clips.append(clip)
|
||||
durations.append(duration)
|
||||
else:
|
||||
logger.warning(f"Recording clip is missing or empty: {recording.path}")
|
||||
|
||||
if not clips:
|
||||
logger.error("No recordings found for the requested time range")
|
||||
return "No recordings found.", 404
|
||||
|
||||
hour_ago = datetime.now() - timedelta(hours=1)
|
||||
@@ -689,15 +712,28 @@ def vod_event(id):
|
||||
try:
|
||||
event: Event = Event.get(Event.id == id)
|
||||
except DoesNotExist:
|
||||
logger.error(f"Event not found: {id}")
|
||||
return "Event not found.", 404
|
||||
|
||||
if not event.has_clip:
|
||||
return "Clip not available", 404
|
||||
logger.error(f"Event does not have recordings: {id}")
|
||||
return "Recordings not available", 404
|
||||
|
||||
clip_path = os.path.join(CLIPS_DIR, f"{event.camera}-{id}.mp4")
|
||||
|
||||
if not os.path.isfile(clip_path):
|
||||
return vod_ts(event.camera, event.start_time, event.end_time)
|
||||
end_ts = (
|
||||
datetime.now().timestamp() if event.end_time is None else event.end_time
|
||||
)
|
||||
vod_response = vod_ts(event.camera, event.start_time, end_ts)
|
||||
# If the recordings are not found, set has_clip to false
|
||||
if (
|
||||
type(vod_response) == tuple
|
||||
and len(vod_response) == 2
|
||||
and vod_response[1] == 404
|
||||
):
|
||||
Event.update(has_clip=False).where(Event.id == id).execute()
|
||||
return vod_response
|
||||
|
||||
duration = int((event.end_time - event.start_time) * 1000)
|
||||
return jsonify(
|
||||
|
@@ -27,3 +27,5 @@ class Recordings(Model):
|
||||
start_time = DateTimeField()
|
||||
end_time = DateTimeField()
|
||||
duration = FloatField()
|
||||
motion = IntegerField(null=True)
|
||||
objects = IntegerField(null=True)
|
||||
|
@@ -23,6 +23,7 @@ class MotionDetector:
|
||||
interpolation=cv2.INTER_LINEAR,
|
||||
)
|
||||
self.mask = np.where(resized_mask == [0])
|
||||
self.save_images = False
|
||||
|
||||
def detect(self, frame):
|
||||
motion_boxes = []
|
||||
@@ -36,10 +37,15 @@ class MotionDetector:
|
||||
interpolation=cv2.INTER_LINEAR,
|
||||
)
|
||||
|
||||
# TODO: can I improve the contrast of the grayscale image here?
|
||||
|
||||
# convert to grayscale
|
||||
# resized_frame = cv2.cvtColor(resized_frame, cv2.COLOR_BGR2GRAY)
|
||||
# Improve contrast
|
||||
minval = np.percentile(resized_frame, 4)
|
||||
maxval = np.percentile(resized_frame, 96)
|
||||
# don't adjust if the image is a single color
|
||||
if minval < maxval:
|
||||
resized_frame = np.clip(resized_frame, minval, maxval)
|
||||
resized_frame = (
|
||||
((resized_frame - minval) / (maxval - minval)) * 255
|
||||
).astype(np.uint8)
|
||||
|
||||
# mask frame
|
||||
resized_frame[self.mask] = [255]
|
||||
@@ -49,6 +55,8 @@ class MotionDetector:
|
||||
if self.frame_counter < 30:
|
||||
self.frame_counter += 1
|
||||
else:
|
||||
if self.save_images:
|
||||
self.frame_counter += 1
|
||||
# compare to average
|
||||
frameDelta = cv2.absdiff(resized_frame, cv2.convertScaleAbs(self.avg_frame))
|
||||
|
||||
@@ -58,7 +66,6 @@ class MotionDetector:
|
||||
cv2.accumulateWeighted(frameDelta, self.avg_delta, self.config.delta_alpha)
|
||||
|
||||
# compute the threshold image for the current frame
|
||||
# TODO: threshold
|
||||
current_thresh = cv2.threshold(
|
||||
frameDelta, self.config.threshold, 255, cv2.THRESH_BINARY
|
||||
)[1]
|
||||
@@ -75,8 +82,10 @@ class MotionDetector:
|
||||
|
||||
# dilate the thresholded image to fill in holes, then find contours
|
||||
# on thresholded image
|
||||
thresh = cv2.dilate(thresh, None, iterations=2)
|
||||
cnts = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
thresh_dilated = cv2.dilate(thresh, None, iterations=2)
|
||||
cnts = cv2.findContours(
|
||||
thresh_dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
|
||||
)
|
||||
cnts = imutils.grab_contours(cnts)
|
||||
|
||||
# loop over the contours
|
||||
@@ -94,6 +103,35 @@ class MotionDetector:
|
||||
)
|
||||
)
|
||||
|
||||
if self.save_images:
|
||||
thresh_dilated = cv2.cvtColor(thresh_dilated, cv2.COLOR_GRAY2BGR)
|
||||
# print("--------")
|
||||
# print(self.frame_counter)
|
||||
for c in cnts:
|
||||
contour_area = cv2.contourArea(c)
|
||||
# print(contour_area)
|
||||
if contour_area > self.config.contour_area:
|
||||
x, y, w, h = cv2.boundingRect(c)
|
||||
cv2.rectangle(
|
||||
thresh_dilated,
|
||||
(x, y),
|
||||
(x + w, y + h),
|
||||
(0, 0, 255),
|
||||
2,
|
||||
)
|
||||
# print("--------")
|
||||
image_row_1 = cv2.hconcat(
|
||||
[
|
||||
cv2.cvtColor(frameDelta, cv2.COLOR_GRAY2BGR),
|
||||
cv2.cvtColor(avg_delta_image, cv2.COLOR_GRAY2BGR),
|
||||
]
|
||||
)
|
||||
image_row_2 = cv2.hconcat(
|
||||
[cv2.cvtColor(thresh, cv2.COLOR_GRAY2BGR), thresh_dilated]
|
||||
)
|
||||
combined_image = cv2.vconcat([image_row_1, image_row_2])
|
||||
cv2.imwrite(f"motion/motion-{self.frame_counter}.jpg", combined_image)
|
||||
|
||||
if len(motion_boxes) > 0:
|
||||
self.motion_frame_count += 1
|
||||
if self.motion_frame_count >= 10:
|
||||
|
@@ -107,7 +107,7 @@ def create_mqtt_client(config: FrigateConfig, camera_metrics):
|
||||
+ str(rc)
|
||||
)
|
||||
|
||||
logger.info("MQTT connected")
|
||||
logger.debug("MQTT connected")
|
||||
client.subscribe(f"{mqtt_config.topic_prefix}/#")
|
||||
client.publish(mqtt_config.topic_prefix + "/available", "online", retain=True)
|
||||
|
||||
|
@@ -18,12 +18,12 @@ import numpy as np
|
||||
|
||||
from frigate.config import CameraConfig, SnapshotsConfig, RecordConfig, FrigateConfig
|
||||
from frigate.const import CACHE_DIR, CLIPS_DIR, RECORD_DIR
|
||||
from frigate.edgetpu import load_labels
|
||||
from frigate.util import (
|
||||
SharedMemoryFrameManager,
|
||||
calculate_region,
|
||||
draw_box_with_label,
|
||||
draw_timestamp,
|
||||
load_labels,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -71,7 +71,7 @@ class TrackedObject:
|
||||
self.camera_config = camera_config
|
||||
self.frame_cache = frame_cache
|
||||
self.current_zones = []
|
||||
self.entered_zones = set()
|
||||
self.entered_zones = []
|
||||
self.false_positive = True
|
||||
self.has_clip = False
|
||||
self.has_snapshot = False
|
||||
@@ -101,14 +101,13 @@ class TrackedObject:
|
||||
return median(scores)
|
||||
|
||||
def update(self, current_frame_time, obj_data):
|
||||
significant_update = False
|
||||
zone_change = False
|
||||
self.obj_data.update(obj_data)
|
||||
thumb_update = False
|
||||
significant_change = False
|
||||
# if the object is not in the current frame, add a 0.0 to the score history
|
||||
if self.obj_data["frame_time"] != current_frame_time:
|
||||
if obj_data["frame_time"] != current_frame_time:
|
||||
self.score_history.append(0.0)
|
||||
else:
|
||||
self.score_history.append(self.obj_data["score"])
|
||||
self.score_history.append(obj_data["score"])
|
||||
# only keep the last 10 scores
|
||||
if len(self.score_history) > 10:
|
||||
self.score_history = self.score_history[-10:]
|
||||
@@ -122,24 +121,24 @@ class TrackedObject:
|
||||
if not self.false_positive:
|
||||
# determine if this frame is a better thumbnail
|
||||
if self.thumbnail_data is None or is_better_thumbnail(
|
||||
self.thumbnail_data, self.obj_data, self.camera_config.frame_shape
|
||||
self.thumbnail_data, obj_data, self.camera_config.frame_shape
|
||||
):
|
||||
self.thumbnail_data = {
|
||||
"frame_time": self.obj_data["frame_time"],
|
||||
"box": self.obj_data["box"],
|
||||
"area": self.obj_data["area"],
|
||||
"region": self.obj_data["region"],
|
||||
"score": self.obj_data["score"],
|
||||
"frame_time": obj_data["frame_time"],
|
||||
"box": obj_data["box"],
|
||||
"area": obj_data["area"],
|
||||
"region": obj_data["region"],
|
||||
"score": obj_data["score"],
|
||||
}
|
||||
significant_update = True
|
||||
thumb_update = True
|
||||
|
||||
# check zones
|
||||
current_zones = []
|
||||
bottom_center = (self.obj_data["centroid"][0], self.obj_data["box"][3])
|
||||
bottom_center = (obj_data["centroid"][0], obj_data["box"][3])
|
||||
# check each zone
|
||||
for name, zone in self.camera_config.zones.items():
|
||||
# if the zone is not for this object type, skip
|
||||
if len(zone.objects) > 0 and not self.obj_data["label"] in zone.objects:
|
||||
if len(zone.objects) > 0 and not obj_data["label"] in zone.objects:
|
||||
continue
|
||||
contour = zone.contour
|
||||
# check if the object is in the zone
|
||||
@@ -147,14 +146,32 @@ class TrackedObject:
|
||||
# if the object passed the filters once, dont apply again
|
||||
if name in self.current_zones or not zone_filtered(self, zone.filters):
|
||||
current_zones.append(name)
|
||||
self.entered_zones.add(name)
|
||||
if name not in self.entered_zones:
|
||||
self.entered_zones.append(name)
|
||||
|
||||
# if the zones changed, signal an update
|
||||
if not self.false_positive and set(self.current_zones) != set(current_zones):
|
||||
zone_change = True
|
||||
if not self.false_positive:
|
||||
# if the zones changed, signal an update
|
||||
if set(self.current_zones) != set(current_zones):
|
||||
significant_change = True
|
||||
|
||||
# if the position changed, signal an update
|
||||
if self.obj_data["position_changes"] != obj_data["position_changes"]:
|
||||
significant_change = True
|
||||
|
||||
# if the motionless_count reaches the stationary threshold
|
||||
if (
|
||||
self.obj_data["motionless_count"]
|
||||
== self.camera_config.detect.stationary_threshold
|
||||
):
|
||||
significant_change = True
|
||||
|
||||
# update at least once per minute
|
||||
if self.obj_data["frame_time"] - self.previous["frame_time"] > 60:
|
||||
significant_change = True
|
||||
|
||||
self.obj_data.update(obj_data)
|
||||
self.current_zones = current_zones
|
||||
return (significant_update, zone_change)
|
||||
return (thumb_update, significant_change)
|
||||
|
||||
def to_dict(self, include_thumbnail: bool = False):
|
||||
snapshot_time = (
|
||||
@@ -176,8 +193,12 @@ class TrackedObject:
|
||||
"box": self.obj_data["box"],
|
||||
"area": self.obj_data["area"],
|
||||
"region": self.obj_data["region"],
|
||||
"stationary": self.obj_data["motionless_count"]
|
||||
> self.camera_config.detect.stationary_threshold,
|
||||
"motionless_count": self.obj_data["motionless_count"],
|
||||
"position_changes": self.obj_data["position_changes"],
|
||||
"current_zones": self.current_zones.copy(),
|
||||
"entered_zones": list(self.entered_zones).copy(),
|
||||
"entered_zones": self.entered_zones.copy(),
|
||||
"has_clip": self.has_clip,
|
||||
"has_snapshot": self.has_snapshot,
|
||||
}
|
||||
@@ -262,8 +283,15 @@ class TrackedObject:
|
||||
|
||||
if crop:
|
||||
box = self.thumbnail_data["box"]
|
||||
box_size = 300
|
||||
region = calculate_region(
|
||||
best_frame.shape, box[0], box[1], box[2], box[3], 1.1
|
||||
best_frame.shape,
|
||||
box[0],
|
||||
box[1],
|
||||
box[2],
|
||||
box[3],
|
||||
box_size,
|
||||
multiplier=1.1,
|
||||
)
|
||||
best_frame = best_frame[region[1] : region[3], region[0] : region[2]]
|
||||
|
||||
@@ -456,11 +484,11 @@ class CameraState:
|
||||
|
||||
for id in updated_ids:
|
||||
updated_obj = tracked_objects[id]
|
||||
significant_update, zone_change = updated_obj.update(
|
||||
thumb_update, significant_update = updated_obj.update(
|
||||
frame_time, current_detections[id]
|
||||
)
|
||||
|
||||
if significant_update:
|
||||
if thumb_update:
|
||||
# ensure this frame is stored in the cache
|
||||
if (
|
||||
updated_obj.thumbnail_data["frame_time"] == frame_time
|
||||
@@ -470,13 +498,13 @@ class CameraState:
|
||||
|
||||
updated_obj.last_updated = frame_time
|
||||
|
||||
# if it has been more than 5 seconds since the last publish
|
||||
# if it has been more than 5 seconds since the last thumb update
|
||||
# and the last update is greater than the last publish or
|
||||
# the object has changed zones
|
||||
# the object has changed significantly
|
||||
if (
|
||||
frame_time - updated_obj.last_published > 5
|
||||
and updated_obj.last_updated > updated_obj.last_published
|
||||
) or zone_change:
|
||||
) or significant_update:
|
||||
# call event handlers
|
||||
for c in self.callbacks["update"]:
|
||||
c(self.name, updated_obj, frame_time)
|
||||
@@ -584,6 +612,7 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
event_queue,
|
||||
event_processed_queue,
|
||||
video_output_queue,
|
||||
recordings_info_queue,
|
||||
stop_event,
|
||||
):
|
||||
threading.Thread.__init__(self)
|
||||
@@ -595,6 +624,7 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
self.event_queue = event_queue
|
||||
self.event_processed_queue = event_processed_queue
|
||||
self.video_output_queue = video_output_queue
|
||||
self.recordings_info_queue = recordings_info_queue
|
||||
self.stop_event = stop_event
|
||||
self.camera_states: Dict[str, CameraState] = {}
|
||||
self.frame_manager = SharedMemoryFrameManager()
|
||||
@@ -603,6 +633,8 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
self.event_queue.put(("start", camera, obj.to_dict()))
|
||||
|
||||
def update(camera, obj: TrackedObject, current_frame_time):
|
||||
obj.has_snapshot = self.should_save_snapshot(camera, obj)
|
||||
obj.has_clip = self.should_retain_recording(camera, obj)
|
||||
after = obj.to_dict()
|
||||
message = {
|
||||
"before": obj.previous,
|
||||
@@ -613,6 +645,9 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
f"{self.topic_prefix}/events", json.dumps(message), retain=False
|
||||
)
|
||||
obj.previous = after
|
||||
self.event_queue.put(
|
||||
("update", camera, obj.to_dict(include_thumbnail=True))
|
||||
)
|
||||
|
||||
def end(camera, obj: TrackedObject, current_frame_time):
|
||||
# populate has_snapshot
|
||||
@@ -722,9 +757,13 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
if not snapshot_config.enabled:
|
||||
return False
|
||||
|
||||
# object never changed position
|
||||
if obj.obj_data["position_changes"] == 0:
|
||||
return False
|
||||
|
||||
# if there are required zones and there is no overlap
|
||||
required_zones = snapshot_config.required_zones
|
||||
if len(required_zones) > 0 and not obj.entered_zones & set(required_zones):
|
||||
if len(required_zones) > 0 and not set(obj.entered_zones) & set(required_zones):
|
||||
logger.debug(
|
||||
f"Not creating snapshot for {obj.obj_data['id']} because it did not enter required zones"
|
||||
)
|
||||
@@ -742,6 +781,10 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
if not record_config.enabled:
|
||||
return False
|
||||
|
||||
# object never changed position
|
||||
if obj.obj_data["position_changes"] == 0:
|
||||
return False
|
||||
|
||||
# If there are required zones and there is no overlap
|
||||
required_zones = record_config.events.required_zones
|
||||
if len(required_zones) > 0 and not set(obj.entered_zones) & set(required_zones):
|
||||
@@ -763,9 +806,13 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
return True
|
||||
|
||||
def should_mqtt_snapshot(self, camera, obj: TrackedObject):
|
||||
# object never changed position
|
||||
if obj.obj_data["position_changes"] == 0:
|
||||
return False
|
||||
|
||||
# if there are required zones and there is no overlap
|
||||
required_zones = self.config.cameras[camera].mqtt.required_zones
|
||||
if len(required_zones) > 0 and not obj.entered_zones & set(required_zones):
|
||||
if len(required_zones) > 0 and not set(obj.entered_zones) & set(required_zones):
|
||||
logger.debug(
|
||||
f"Not sending mqtt for {obj.obj_data['id']} because it did not enter required zones"
|
||||
)
|
||||
@@ -808,11 +855,26 @@ class TrackedObjectProcessor(threading.Thread):
|
||||
frame_time, current_tracked_objects, motion_boxes, regions
|
||||
)
|
||||
|
||||
tracked_objects = [
|
||||
o.to_dict() for o in camera_state.tracked_objects.values()
|
||||
]
|
||||
|
||||
self.video_output_queue.put(
|
||||
(
|
||||
camera,
|
||||
frame_time,
|
||||
current_tracked_objects,
|
||||
tracked_objects,
|
||||
motion_boxes,
|
||||
regions,
|
||||
)
|
||||
)
|
||||
|
||||
# send info on this frame to the recordings maintainer
|
||||
self.recordings_info_queue.put(
|
||||
(
|
||||
camera,
|
||||
frame_time,
|
||||
tracked_objects,
|
||||
motion_boxes,
|
||||
regions,
|
||||
)
|
||||
|
@@ -13,31 +13,109 @@ import numpy as np
|
||||
from scipy.spatial import distance as dist
|
||||
|
||||
from frigate.config import DetectConfig
|
||||
from frigate.util import draw_box_with_label
|
||||
from frigate.util import intersection_over_union
|
||||
|
||||
|
||||
class ObjectTracker:
|
||||
def __init__(self, config: DetectConfig):
|
||||
self.tracked_objects = {}
|
||||
self.disappeared = {}
|
||||
self.positions = {}
|
||||
self.max_disappeared = config.max_disappeared
|
||||
self.detect_config = config
|
||||
|
||||
def register(self, index, obj):
|
||||
rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
|
||||
id = f"{obj['frame_time']}-{rand_id}"
|
||||
obj["id"] = id
|
||||
obj["start_time"] = obj["frame_time"]
|
||||
obj["motionless_count"] = 0
|
||||
obj["position_changes"] = 0
|
||||
self.tracked_objects[id] = obj
|
||||
self.disappeared[id] = 0
|
||||
self.positions[id] = {
|
||||
"xmins": [],
|
||||
"ymins": [],
|
||||
"xmaxs": [],
|
||||
"ymaxs": [],
|
||||
"xmin": 0,
|
||||
"ymin": 0,
|
||||
"xmax": self.detect_config.width,
|
||||
"ymax": self.detect_config.height,
|
||||
}
|
||||
|
||||
def deregister(self, id):
|
||||
del self.tracked_objects[id]
|
||||
del self.disappeared[id]
|
||||
|
||||
# tracks the current position of the object based on the last N bounding boxes
|
||||
# returns False if the object has moved outside its previous position
|
||||
def update_position(self, id, box):
|
||||
position = self.positions[id]
|
||||
position_box = (
|
||||
position["xmin"],
|
||||
position["ymin"],
|
||||
position["xmax"],
|
||||
position["ymax"],
|
||||
)
|
||||
|
||||
xmin, ymin, xmax, ymax = box
|
||||
|
||||
iou = intersection_over_union(position_box, box)
|
||||
|
||||
# if the iou drops below the threshold
|
||||
# assume the object has moved to a new position and reset the computed box
|
||||
if iou < 0.6:
|
||||
self.positions[id] = {
|
||||
"xmins": [xmin],
|
||||
"ymins": [ymin],
|
||||
"xmaxs": [xmax],
|
||||
"ymaxs": [ymax],
|
||||
"xmin": xmin,
|
||||
"ymin": ymin,
|
||||
"xmax": xmax,
|
||||
"ymax": ymax,
|
||||
}
|
||||
return False
|
||||
|
||||
# if there are less than 10 entries for the position, add the bounding box
|
||||
# and recompute the position box
|
||||
if len(position["xmins"]) < 10:
|
||||
position["xmins"].append(xmin)
|
||||
position["ymins"].append(ymin)
|
||||
position["xmaxs"].append(xmax)
|
||||
position["ymaxs"].append(ymax)
|
||||
# by using percentiles here, we hopefully remove outliers
|
||||
position["xmin"] = np.percentile(position["xmins"], 15)
|
||||
position["ymin"] = np.percentile(position["ymins"], 15)
|
||||
position["xmax"] = np.percentile(position["xmaxs"], 85)
|
||||
position["ymax"] = np.percentile(position["ymaxs"], 85)
|
||||
|
||||
return True
|
||||
|
||||
def update(self, id, new_obj):
|
||||
self.disappeared[id] = 0
|
||||
# update the motionless count if the object has not moved to a new position
|
||||
if self.update_position(id, new_obj["box"]):
|
||||
self.tracked_objects[id]["motionless_count"] += 1
|
||||
else:
|
||||
# register the first position change and then only increment if
|
||||
# the object was previously stationary
|
||||
if (
|
||||
self.tracked_objects[id]["position_changes"] == 0
|
||||
or self.tracked_objects[id]["motionless_count"]
|
||||
>= self.detect_config.stationary_threshold
|
||||
):
|
||||
self.tracked_objects[id]["position_changes"] += 1
|
||||
self.tracked_objects[id]["motionless_count"] = 0
|
||||
|
||||
self.tracked_objects[id].update(new_obj)
|
||||
|
||||
def update_frame_times(self, frame_time):
|
||||
for id in self.tracked_objects.keys():
|
||||
self.tracked_objects[id]["frame_time"] = frame_time
|
||||
self.tracked_objects[id]["motionless_count"] += 1
|
||||
|
||||
def match_and_update(self, frame_time, new_objects):
|
||||
# group by name
|
||||
new_object_groups = defaultdict(lambda: [])
|
||||
|
@@ -184,10 +184,7 @@ class BirdsEyeFrameManager:
|
||||
if self.mode == BirdseyeModeEnum.continuous:
|
||||
return True
|
||||
|
||||
if (
|
||||
self.mode == BirdseyeModeEnum.motion
|
||||
and object_box_count + motion_box_count > 0
|
||||
):
|
||||
if self.mode == BirdseyeModeEnum.motion and motion_box_count > 0:
|
||||
return True
|
||||
|
||||
if self.mode == BirdseyeModeEnum.objects and object_box_count > 0:
|
||||
@@ -418,7 +415,7 @@ def output_frames(config: FrigateConfig, video_output_queue):
|
||||
):
|
||||
if birdseye_manager.update(
|
||||
camera,
|
||||
len(current_tracked_objects),
|
||||
len([o for o in current_tracked_objects if not o["stationary"]]),
|
||||
len(motion_boxes),
|
||||
frame_time,
|
||||
frame,
|
||||
|
@@ -1,20 +1,25 @@
|
||||
import datetime
|
||||
import itertools
|
||||
import logging
|
||||
import multiprocessing as mp
|
||||
import os
|
||||
import queue
|
||||
import random
|
||||
import shutil
|
||||
import string
|
||||
import subprocess as sp
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
import psutil
|
||||
from peewee import JOIN, DoesNotExist
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.config import RetainModeEnum, FrigateConfig
|
||||
from frigate.const import CACHE_DIR, RECORD_DIR
|
||||
from frigate.models import Event, Recordings
|
||||
from frigate.util import area
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -38,20 +43,27 @@ def remove_empty_directories(directory):
|
||||
|
||||
|
||||
class RecordingMaintainer(threading.Thread):
|
||||
def __init__(self, config: FrigateConfig, stop_event):
|
||||
def __init__(
|
||||
self, config: FrigateConfig, recordings_info_queue: mp.Queue, stop_event
|
||||
):
|
||||
threading.Thread.__init__(self)
|
||||
self.name = "recording_maint"
|
||||
self.config = config
|
||||
self.recordings_info_queue = recordings_info_queue
|
||||
self.stop_event = stop_event
|
||||
self.recordings_info = defaultdict(list)
|
||||
self.end_time_cache = {}
|
||||
|
||||
def move_files(self):
|
||||
recordings = [
|
||||
d
|
||||
for d in os.listdir(CACHE_DIR)
|
||||
if os.path.isfile(os.path.join(CACHE_DIR, d))
|
||||
and d.endswith(".mp4")
|
||||
and not d.startswith("clip_")
|
||||
]
|
||||
cache_files = sorted(
|
||||
[
|
||||
d
|
||||
for d in os.listdir(CACHE_DIR)
|
||||
if os.path.isfile(os.path.join(CACHE_DIR, d))
|
||||
and d.endswith(".mp4")
|
||||
and not d.startswith("clip_")
|
||||
]
|
||||
)
|
||||
|
||||
files_in_use = []
|
||||
for process in psutil.process_iter():
|
||||
@@ -66,7 +78,9 @@ class RecordingMaintainer(threading.Thread):
|
||||
except:
|
||||
continue
|
||||
|
||||
for f in recordings:
|
||||
# group recordings by camera
|
||||
grouped_recordings = defaultdict(list)
|
||||
for f in cache_files:
|
||||
# Skip files currently in use
|
||||
if f in files_in_use:
|
||||
continue
|
||||
@@ -76,45 +90,187 @@ class RecordingMaintainer(threading.Thread):
|
||||
camera, date = basename.rsplit("-", maxsplit=1)
|
||||
start_time = datetime.datetime.strptime(date, "%Y%m%d%H%M%S")
|
||||
|
||||
# Just delete files if recordings are turned off
|
||||
if (
|
||||
not camera in self.config.cameras
|
||||
or not self.config.cameras[camera].record.enabled
|
||||
):
|
||||
Path(cache_path).unlink(missing_ok=True)
|
||||
continue
|
||||
|
||||
ffprobe_cmd = [
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
f"{cache_path}",
|
||||
]
|
||||
p = sp.run(ffprobe_cmd, capture_output=True)
|
||||
if p.returncode == 0:
|
||||
duration = float(p.stdout.decode().strip())
|
||||
end_time = start_time + datetime.timedelta(seconds=duration)
|
||||
else:
|
||||
logger.warning(f"Discarding a corrupt recording segment: {f}")
|
||||
Path(cache_path).unlink(missing_ok=True)
|
||||
continue
|
||||
|
||||
directory = os.path.join(
|
||||
RECORD_DIR, start_time.strftime("%Y-%m/%d/%H"), camera
|
||||
grouped_recordings[camera].append(
|
||||
{
|
||||
"cache_path": cache_path,
|
||||
"start_time": start_time,
|
||||
}
|
||||
)
|
||||
|
||||
if not os.path.exists(directory):
|
||||
os.makedirs(directory)
|
||||
# delete all cached files past the most recent 5
|
||||
keep_count = 5
|
||||
for camera in grouped_recordings.keys():
|
||||
if len(grouped_recordings[camera]) > keep_count:
|
||||
to_remove = grouped_recordings[camera][:-keep_count]
|
||||
for f in to_remove:
|
||||
Path(f["cache_path"]).unlink(missing_ok=True)
|
||||
self.end_time_cache.pop(f["cache_path"], None)
|
||||
grouped_recordings[camera] = grouped_recordings[camera][-keep_count:]
|
||||
|
||||
file_name = f"{start_time.strftime('%M.%S.mp4')}"
|
||||
file_path = os.path.join(directory, file_name)
|
||||
for camera, recordings in grouped_recordings.items():
|
||||
|
||||
# clear out all the recording info for old frames
|
||||
while (
|
||||
len(self.recordings_info[camera]) > 0
|
||||
and self.recordings_info[camera][0][0]
|
||||
< recordings[0]["start_time"].timestamp()
|
||||
):
|
||||
self.recordings_info[camera].pop(0)
|
||||
|
||||
# get all events with the end time after the start of the oldest cache file
|
||||
# or with end_time None
|
||||
events: Event = (
|
||||
Event.select()
|
||||
.where(
|
||||
Event.camera == camera,
|
||||
(Event.end_time == None)
|
||||
| (Event.end_time >= recordings[0]["start_time"].timestamp()),
|
||||
Event.has_clip,
|
||||
)
|
||||
.order_by(Event.start_time)
|
||||
)
|
||||
for r in recordings:
|
||||
cache_path = r["cache_path"]
|
||||
start_time = r["start_time"]
|
||||
|
||||
# Just delete files if recordings are turned off
|
||||
if (
|
||||
not camera in self.config.cameras
|
||||
or not self.config.cameras[camera].record.enabled
|
||||
):
|
||||
Path(cache_path).unlink(missing_ok=True)
|
||||
self.end_time_cache.pop(cache_path, None)
|
||||
continue
|
||||
|
||||
if cache_path in self.end_time_cache:
|
||||
end_time, duration = self.end_time_cache[cache_path]
|
||||
else:
|
||||
ffprobe_cmd = [
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
f"{cache_path}",
|
||||
]
|
||||
p = sp.run(ffprobe_cmd, capture_output=True)
|
||||
if p.returncode == 0:
|
||||
duration = float(p.stdout.decode().strip())
|
||||
end_time = start_time + datetime.timedelta(seconds=duration)
|
||||
self.end_time_cache[cache_path] = (end_time, duration)
|
||||
else:
|
||||
logger.warning(f"Discarding a corrupt recording segment: {f}")
|
||||
Path(cache_path).unlink(missing_ok=True)
|
||||
continue
|
||||
|
||||
# if cached file's start_time is earlier than the retain days for the camera
|
||||
if start_time <= (
|
||||
(
|
||||
datetime.datetime.now()
|
||||
- datetime.timedelta(
|
||||
days=self.config.cameras[camera].record.retain.days
|
||||
)
|
||||
)
|
||||
):
|
||||
# if the cached segment overlaps with the events:
|
||||
overlaps = False
|
||||
for event in events:
|
||||
# if the event starts in the future, stop checking events
|
||||
# and remove this segment
|
||||
if event.start_time > end_time.timestamp():
|
||||
overlaps = False
|
||||
Path(cache_path).unlink(missing_ok=True)
|
||||
self.end_time_cache.pop(cache_path, None)
|
||||
break
|
||||
|
||||
# if the event is in progress or ends after the recording starts, keep it
|
||||
# and stop looking at events
|
||||
if (
|
||||
event.end_time is None
|
||||
or event.end_time >= start_time.timestamp()
|
||||
):
|
||||
overlaps = True
|
||||
break
|
||||
|
||||
if overlaps:
|
||||
record_mode = self.config.cameras[
|
||||
camera
|
||||
].record.events.retain.mode
|
||||
# move from cache to recordings immediately
|
||||
self.store_segment(
|
||||
camera,
|
||||
start_time,
|
||||
end_time,
|
||||
duration,
|
||||
cache_path,
|
||||
record_mode,
|
||||
)
|
||||
# else retain days includes this segment
|
||||
else:
|
||||
record_mode = self.config.cameras[camera].record.retain.mode
|
||||
self.store_segment(
|
||||
camera, start_time, end_time, duration, cache_path, record_mode
|
||||
)
|
||||
|
||||
def segment_stats(self, camera, start_time, end_time):
|
||||
active_count = 0
|
||||
motion_count = 0
|
||||
for frame in self.recordings_info[camera]:
|
||||
# frame is after end time of segment
|
||||
if frame[0] > end_time.timestamp():
|
||||
break
|
||||
# frame is before start time of segment
|
||||
if frame[0] < start_time.timestamp():
|
||||
continue
|
||||
|
||||
active_count += len(
|
||||
[
|
||||
o
|
||||
for o in frame[1]
|
||||
if not o["false_positive"] and o["motionless_count"] == 0
|
||||
]
|
||||
)
|
||||
|
||||
motion_count += sum([area(box) for box in frame[2]])
|
||||
|
||||
return (motion_count, active_count)
|
||||
|
||||
def store_segment(
|
||||
self,
|
||||
camera,
|
||||
start_time,
|
||||
end_time,
|
||||
duration,
|
||||
cache_path,
|
||||
store_mode: RetainModeEnum,
|
||||
):
|
||||
motion_count, active_count = self.segment_stats(camera, start_time, end_time)
|
||||
|
||||
# check if the segment shouldn't be stored
|
||||
if (store_mode == RetainModeEnum.motion and motion_count == 0) or (
|
||||
store_mode == RetainModeEnum.active_objects and active_count == 0
|
||||
):
|
||||
Path(cache_path).unlink(missing_ok=True)
|
||||
self.end_time_cache.pop(cache_path, None)
|
||||
return
|
||||
|
||||
directory = os.path.join(RECORD_DIR, start_time.strftime("%Y-%m/%d/%H"), camera)
|
||||
|
||||
if not os.path.exists(directory):
|
||||
os.makedirs(directory)
|
||||
|
||||
file_name = f"{start_time.strftime('%M.%S.mp4')}"
|
||||
file_path = os.path.join(directory, file_name)
|
||||
|
||||
try:
|
||||
start_frame = datetime.datetime.now().timestamp()
|
||||
# copy then delete is required when recordings are stored on some network drives
|
||||
shutil.copyfile(cache_path, file_path)
|
||||
logger.debug(
|
||||
f"Copied {file_path} in {datetime.datetime.now().timestamp()-start_frame} seconds."
|
||||
)
|
||||
os.remove(cache_path)
|
||||
|
||||
rand_id = "".join(
|
||||
@@ -127,15 +283,56 @@ class RecordingMaintainer(threading.Thread):
|
||||
start_time=start_time.timestamp(),
|
||||
end_time=end_time.timestamp(),
|
||||
duration=duration,
|
||||
motion=motion_count,
|
||||
# TODO: update this to store list of active objects at some point
|
||||
objects=active_count,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Unable to store recording segment {cache_path}")
|
||||
Path(cache_path).unlink(missing_ok=True)
|
||||
logger.error(e)
|
||||
|
||||
# clear end_time cache
|
||||
self.end_time_cache.pop(cache_path, None)
|
||||
|
||||
def run(self):
|
||||
# Check for new files every 5 seconds
|
||||
wait_time = 5
|
||||
while not self.stop_event.wait(wait_time):
|
||||
run_start = datetime.datetime.now().timestamp()
|
||||
self.move_files()
|
||||
wait_time = max(0, 5 - (datetime.datetime.now().timestamp() - run_start))
|
||||
|
||||
# empty the recordings info queue
|
||||
while True:
|
||||
try:
|
||||
(
|
||||
camera,
|
||||
frame_time,
|
||||
current_tracked_objects,
|
||||
motion_boxes,
|
||||
regions,
|
||||
) = self.recordings_info_queue.get(False)
|
||||
|
||||
if self.config.cameras[camera].record.enabled:
|
||||
self.recordings_info[camera].append(
|
||||
(
|
||||
frame_time,
|
||||
current_tracked_objects,
|
||||
motion_boxes,
|
||||
regions,
|
||||
)
|
||||
)
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
try:
|
||||
self.move_files()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error occurred when attempting to maintain recording cache"
|
||||
)
|
||||
logger.error(e)
|
||||
duration = datetime.datetime.now().timestamp() - run_start
|
||||
wait_time = max(0, 5 - duration)
|
||||
|
||||
logger.info(f"Exiting recording maintenance...")
|
||||
|
||||
@@ -160,7 +357,7 @@ class RecordingCleanup(threading.Thread):
|
||||
|
||||
logger.debug("Start deleted cameras.")
|
||||
# Handle deleted cameras
|
||||
expire_days = self.config.record.retain_days
|
||||
expire_days = self.config.record.retain.days
|
||||
expire_before = (
|
||||
datetime.datetime.now() - datetime.timedelta(days=expire_days)
|
||||
).timestamp()
|
||||
@@ -186,7 +383,7 @@ class RecordingCleanup(threading.Thread):
|
||||
datetime.datetime.now()
|
||||
- datetime.timedelta(seconds=config.record.events.max_seconds)
|
||||
).timestamp()
|
||||
expire_days = config.record.retain_days
|
||||
expire_days = config.record.retain.days
|
||||
expire_before = (
|
||||
datetime.datetime.now() - datetime.timedelta(days=expire_days)
|
||||
).timestamp()
|
||||
@@ -217,6 +414,7 @@ class RecordingCleanup(threading.Thread):
|
||||
)
|
||||
|
||||
# loop over recordings and see if they overlap with any non-expired events
|
||||
# TODO: expire segments based on segment stats according to config
|
||||
event_start = 0
|
||||
deleted_recordings = set()
|
||||
for recording in recordings.objects().iterator():
|
||||
@@ -231,9 +429,9 @@ class RecordingCleanup(threading.Thread):
|
||||
keep = False
|
||||
break
|
||||
|
||||
# if the event ends after the recording starts, keep it
|
||||
# if the event is in progress or ends after the recording starts, keep it
|
||||
# and stop looking at events
|
||||
if event.end_time >= recording.start_time:
|
||||
if event.end_time is None or event.end_time >= recording.start_time:
|
||||
keep = True
|
||||
break
|
||||
|
||||
@@ -244,8 +442,19 @@ class RecordingCleanup(threading.Thread):
|
||||
if event.end_time < recording.start_time:
|
||||
event_start = idx
|
||||
|
||||
# Delete recordings outside of the retention window
|
||||
if not keep:
|
||||
# Delete recordings outside of the retention window or based on the retention mode
|
||||
if (
|
||||
not keep
|
||||
or (
|
||||
config.record.events.retain.mode == RetainModeEnum.motion
|
||||
and recording.motion == 0
|
||||
)
|
||||
or (
|
||||
config.record.events.retain.mode
|
||||
== RetainModeEnum.active_objects
|
||||
and recording.objects == 0
|
||||
)
|
||||
):
|
||||
Path(recording.path).unlink(missing_ok=True)
|
||||
deleted_recordings.add(recording.id)
|
||||
|
||||
@@ -262,14 +471,14 @@ class RecordingCleanup(threading.Thread):
|
||||
|
||||
default_expire = (
|
||||
datetime.datetime.now().timestamp()
|
||||
- SECONDS_IN_DAY * self.config.record.retain_days
|
||||
- SECONDS_IN_DAY * self.config.record.retain.days
|
||||
)
|
||||
delete_before = {}
|
||||
|
||||
for name, camera in self.config.cameras.items():
|
||||
delete_before[name] = (
|
||||
datetime.datetime.now().timestamp()
|
||||
- SECONDS_IN_DAY * camera.record.retain_days
|
||||
- SECONDS_IN_DAY * camera.record.retain.days
|
||||
)
|
||||
|
||||
# find all the recordings older than the oldest recording in the db
|
||||
@@ -280,6 +489,10 @@ class RecordingCleanup(threading.Thread):
|
||||
oldest_timestamp = p.stat().st_mtime - 1
|
||||
except DoesNotExist:
|
||||
oldest_timestamp = datetime.datetime.now().timestamp()
|
||||
except FileNotFoundError:
|
||||
logger.warning(f"Unable to find file from recordings database: {p}")
|
||||
Recordings.delete().where(Recordings.id == oldest_recording.id).execute()
|
||||
return
|
||||
|
||||
logger.debug(f"Oldest recording in the db: {oldest_timestamp}")
|
||||
process = sp.run(
|
||||
@@ -291,21 +504,52 @@ class RecordingCleanup(threading.Thread):
|
||||
|
||||
for f in files_to_check:
|
||||
p = Path(f)
|
||||
if p.stat().st_mtime < delete_before.get(p.parent.name, default_expire):
|
||||
p.unlink(missing_ok=True)
|
||||
try:
|
||||
if p.stat().st_mtime < delete_before.get(p.parent.name, default_expire):
|
||||
p.unlink(missing_ok=True)
|
||||
except FileNotFoundError:
|
||||
logger.warning(f"Attempted to expire missing file: {f}")
|
||||
|
||||
logger.debug("End expire files (legacy).")
|
||||
|
||||
def sync_recordings(self):
|
||||
logger.debug("Start sync recordings.")
|
||||
|
||||
# get all recordings in the db
|
||||
recordings: Recordings = Recordings.select()
|
||||
|
||||
# get all recordings files on disk
|
||||
process = sp.run(
|
||||
["find", RECORD_DIR, "-type", "f"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
files_on_disk = process.stdout.splitlines()
|
||||
|
||||
recordings_to_delete = []
|
||||
for recording in recordings.objects().iterator():
|
||||
if not recording.path in files_on_disk:
|
||||
recordings_to_delete.append(recording.id)
|
||||
|
||||
logger.debug(
|
||||
f"Deleting {len(recordings_to_delete)} recordings with missing files"
|
||||
)
|
||||
Recordings.delete().where(Recordings.id << recordings_to_delete).execute()
|
||||
|
||||
logger.debug("End sync recordings.")
|
||||
|
||||
def run(self):
|
||||
# Expire recordings every minute, clean directories every hour.
|
||||
for counter in itertools.cycle(range(60)):
|
||||
# on startup sync recordings with disk (disabled due to too much CPU usage)
|
||||
# self.sync_recordings()
|
||||
|
||||
# Expire tmp clips every minute, recordings and clean directories every hour.
|
||||
for counter in itertools.cycle(range(self.config.record.expire_interval)):
|
||||
if self.stop_event.wait(60):
|
||||
logger.info(f"Exiting recording cleanup...")
|
||||
break
|
||||
|
||||
self.expire_recordings()
|
||||
self.clean_tmp_clips()
|
||||
|
||||
if counter == 0:
|
||||
self.expire_recordings()
|
||||
self.expire_files()
|
||||
remove_empty_directories(RECORD_DIR)
|
||||
|
@@ -4,6 +4,7 @@ import threading
|
||||
import time
|
||||
import psutil
|
||||
import shutil
|
||||
import os
|
||||
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.const import RECORD_DIR, CLIPS_DIR, CACHE_DIR
|
||||
@@ -31,6 +32,28 @@ def get_fs_type(path):
|
||||
return fsType
|
||||
|
||||
|
||||
def read_temperature(path):
|
||||
if os.path.isfile(path):
|
||||
with open(path) as f:
|
||||
line = f.readline().strip()
|
||||
return int(line) / 1000
|
||||
return None
|
||||
|
||||
|
||||
def get_temperatures():
|
||||
temps = {}
|
||||
|
||||
# Get temperatures for all attached Corals
|
||||
base = "/sys/class/apex/"
|
||||
if os.path.isdir(base):
|
||||
for apex in os.listdir(base):
|
||||
temp = read_temperature(os.path.join(base, apex, "temp"))
|
||||
if temp is not None:
|
||||
temps[apex] = temp
|
||||
|
||||
return temps
|
||||
|
||||
|
||||
def stats_snapshot(stats_tracking):
|
||||
camera_metrics = stats_tracking["camera_metrics"]
|
||||
stats = {}
|
||||
@@ -61,6 +84,7 @@ def stats_snapshot(stats_tracking):
|
||||
"uptime": (int(time.time()) - stats_tracking["started"]),
|
||||
"version": VERSION,
|
||||
"storage": {},
|
||||
"temperatures": get_temperatures(),
|
||||
}
|
||||
|
||||
for path in [RECORD_DIR, CLIPS_DIR, CACHE_DIR, "/dev/shm"]:
|
||||
|
@@ -572,7 +572,7 @@ class TestConfig(unittest.TestCase):
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config
|
||||
assert runtime_config.cameras["back"].motion.frame_height >= 120
|
||||
assert runtime_config.cameras["back"].motion.frame_height == 50
|
||||
|
||||
def test_motion_contour_area_dynamic(self):
|
||||
|
||||
@@ -601,7 +601,7 @@ class TestConfig(unittest.TestCase):
|
||||
assert config == frigate_config.dict(exclude_unset=True)
|
||||
|
||||
runtime_config = frigate_config.runtime_config
|
||||
assert round(runtime_config.cameras["back"].motion.contour_area) == 99
|
||||
assert round(runtime_config.cameras["back"].motion.contour_area) == 30
|
||||
|
||||
def test_merge_labelmap(self):
|
||||
|
||||
@@ -1244,6 +1244,30 @@ class TestConfig(unittest.TestCase):
|
||||
runtime_config = frigate_config.runtime_config
|
||||
assert runtime_config.cameras["back"].snapshots.retain.default == 1.5
|
||||
|
||||
def test_fails_on_bad_camera_name(self):
|
||||
config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"snapshots": {"retain": {"default": 1.5}},
|
||||
"cameras": {
|
||||
"back camer#": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{
|
||||
"path": "rtsp://10.0.0.1:554/video",
|
||||
"roles": ["detect"],
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
frigate_config = FrigateConfig(**config)
|
||||
|
||||
self.assertRaises(
|
||||
ValidationError, lambda: frigate_config.runtime_config.cameras
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
|
26
frigate/test/test_reduce_boxes.py
Normal file
26
frigate/test/test_reduce_boxes.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import numpy as np
|
||||
from unittest import TestCase, main
|
||||
from frigate.video import box_overlaps, reduce_boxes
|
||||
|
||||
|
||||
class TestBoxOverlaps(TestCase):
|
||||
def test_overlap(self):
|
||||
assert box_overlaps((100, 100, 200, 200), (50, 50, 150, 150))
|
||||
|
||||
def test_overlap_2(self):
|
||||
assert box_overlaps((50, 50, 150, 150), (100, 100, 200, 200))
|
||||
|
||||
def test_no_overlap(self):
|
||||
assert not box_overlaps((100, 100, 200, 200), (250, 250, 350, 350))
|
||||
|
||||
|
||||
class TestReduceBoxes(TestCase):
|
||||
def test_cluster(self):
|
||||
clusters = reduce_boxes(
|
||||
[(144, 290, 221, 459), (225, 178, 426, 341), (343, 105, 584, 250)]
|
||||
)
|
||||
assert len(clusters) == 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(verbosity=2)
|
@@ -189,12 +189,12 @@ def draw_box_with_label(
|
||||
)
|
||||
|
||||
|
||||
def calculate_region(frame_shape, xmin, ymin, xmax, ymax, multiplier=2):
|
||||
def calculate_region(frame_shape, xmin, ymin, xmax, ymax, model_size, multiplier=2):
|
||||
# size is the longest edge and divisible by 4
|
||||
size = int(max(xmax - xmin, ymax - ymin) // 4 * 4 * multiplier)
|
||||
# dont go any smaller than 300
|
||||
if size < 300:
|
||||
size = 300
|
||||
size = int((max(xmax - xmin, ymax - ymin) * multiplier) // 4 * 4)
|
||||
# dont go any smaller than the model_size
|
||||
if size < model_size:
|
||||
size = model_size
|
||||
|
||||
# x_offset is midpoint of bounding box minus half the size
|
||||
x_offset = int((xmax - xmin) / 2.0 + xmin - size / 2.0)
|
||||
@@ -567,6 +567,9 @@ class EventsPerSecond:
|
||||
# compute the (approximate) events in the last n seconds
|
||||
now = datetime.datetime.now().timestamp()
|
||||
seconds = min(now - self._start, last_n_seconds)
|
||||
# avoid divide by zero
|
||||
if seconds == 0:
|
||||
seconds = 1
|
||||
return (
|
||||
len([t for t in self._timestamps if t > (now - last_n_seconds)]) / seconds
|
||||
)
|
||||
@@ -602,6 +605,26 @@ def add_mask(mask, mask_img):
|
||||
cv2.fillPoly(mask_img, pts=[contour], color=(0))
|
||||
|
||||
|
||||
def load_labels(path, encoding="utf-8"):
|
||||
"""Loads labels from file (with or without index numbers).
|
||||
Args:
|
||||
path: path to label file.
|
||||
encoding: label file encoding.
|
||||
Returns:
|
||||
Dictionary mapping indices to labels.
|
||||
"""
|
||||
with open(path, "r", encoding=encoding) as f:
|
||||
lines = f.readlines()
|
||||
if not lines:
|
||||
return {}
|
||||
|
||||
if lines[0].split(" ", maxsplit=1)[0].isdigit():
|
||||
pairs = [line.split(" ", maxsplit=1) for line in lines]
|
||||
return {int(index): label.strip() for index, label in pairs}
|
||||
else:
|
||||
return {index: line.strip() for index, line in enumerate(lines)}
|
||||
|
||||
|
||||
class FrameManager(ABC):
|
||||
@abstractmethod
|
||||
def create(self, name, size) -> AnyStr:
|
||||
|
360
frigate/video.py
360
frigate/video.py
@@ -3,18 +3,19 @@ import itertools
|
||||
import logging
|
||||
import multiprocessing as mp
|
||||
import queue
|
||||
import subprocess as sp
|
||||
import random
|
||||
import signal
|
||||
import subprocess as sp
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from setproctitle import setproctitle
|
||||
from typing import Dict, List
|
||||
|
||||
from cv2 import cv2
|
||||
import numpy as np
|
||||
from cv2 import cv2, reduce
|
||||
from setproctitle import setproctitle
|
||||
|
||||
from frigate.config import CameraConfig
|
||||
from frigate.config import CameraConfig, DetectConfig
|
||||
from frigate.edgetpu import RemoteObjectDetector
|
||||
from frigate.log import LogPipe
|
||||
from frigate.motion import MotionDetector
|
||||
@@ -23,8 +24,11 @@ from frigate.util import (
|
||||
EventsPerSecond,
|
||||
FrameManager,
|
||||
SharedMemoryFrameManager,
|
||||
area,
|
||||
calculate_region,
|
||||
clipped,
|
||||
intersection,
|
||||
intersection_over_union,
|
||||
listen,
|
||||
yuv_region_2_rgb,
|
||||
)
|
||||
@@ -149,10 +153,10 @@ def capture_frames(
|
||||
try:
|
||||
frame_buffer[:] = ffmpeg_process.stdout.read(frame_size)
|
||||
except Exception as e:
|
||||
logger.info(f"{camera_name}: ffmpeg sent a broken frame. {e}")
|
||||
logger.error(f"{camera_name}: Unable to read frames from ffmpeg process.")
|
||||
|
||||
if ffmpeg_process.poll() != None:
|
||||
logger.info(
|
||||
logger.error(
|
||||
f"{camera_name}: ffmpeg process is not running. exiting capture thread..."
|
||||
)
|
||||
frame_manager.delete(frame_name)
|
||||
@@ -217,12 +221,11 @@ class CameraWatchdog(threading.Thread):
|
||||
|
||||
if not self.capture_thread.is_alive():
|
||||
self.logger.error(
|
||||
f"FFMPEG process crashed unexpectedly for {self.camera_name}."
|
||||
f"Ffmpeg process crashed unexpectedly for {self.camera_name}."
|
||||
)
|
||||
self.logger.error(
|
||||
"The following ffmpeg logs include the last 100 lines prior to exit."
|
||||
)
|
||||
self.logger.error("You may have invalid args defined for this camera.")
|
||||
self.logpipe.dump()
|
||||
self.start_ffmpeg_detect()
|
||||
elif now - self.capture_thread.current_frame.value > 20:
|
||||
@@ -364,6 +367,7 @@ def track_camera(
|
||||
frame_queue,
|
||||
frame_shape,
|
||||
model_shape,
|
||||
config.detect,
|
||||
frame_manager,
|
||||
motion_detector,
|
||||
object_detector,
|
||||
@@ -379,26 +383,36 @@ def track_camera(
|
||||
logger.info(f"{name}: exiting subprocess")
|
||||
|
||||
|
||||
def reduce_boxes(boxes):
|
||||
if len(boxes) == 0:
|
||||
return []
|
||||
reduced_boxes = cv2.groupRectangles(
|
||||
[list(b) for b in itertools.chain(boxes, boxes)], 1, 0.2
|
||||
)[0]
|
||||
return [tuple(b) for b in reduced_boxes]
|
||||
def box_overlaps(b1, b2):
|
||||
if b1[2] < b2[0] or b1[0] > b2[2] or b1[1] > b2[3] or b1[3] < b2[1]:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def reduce_boxes(boxes, iou_threshold=0.0):
|
||||
clusters = []
|
||||
|
||||
for box in boxes:
|
||||
matched = 0
|
||||
for cluster in clusters:
|
||||
if intersection_over_union(box, cluster) > iou_threshold:
|
||||
matched = 1
|
||||
cluster[0] = min(cluster[0], box[0])
|
||||
cluster[1] = min(cluster[1], box[1])
|
||||
cluster[2] = max(cluster[2], box[2])
|
||||
cluster[3] = max(cluster[3], box[3])
|
||||
|
||||
if not matched:
|
||||
clusters.append(list(box))
|
||||
|
||||
return [tuple(c) for c in clusters]
|
||||
|
||||
|
||||
# modified from https://stackoverflow.com/a/40795835
|
||||
def intersects_any(box_a, boxes):
|
||||
for box in boxes:
|
||||
if (
|
||||
box_a[2] < box[0]
|
||||
or box_a[0] > box[2]
|
||||
or box_a[1] > box[3]
|
||||
or box_a[3] < box[1]
|
||||
):
|
||||
continue
|
||||
return True
|
||||
if box_overlaps(box_a, box):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def detect(
|
||||
@@ -434,6 +448,7 @@ def process_frames(
|
||||
frame_queue: mp.Queue,
|
||||
frame_shape,
|
||||
model_shape,
|
||||
detect_config: DetectConfig,
|
||||
frame_manager: FrameManager,
|
||||
motion_detector: MotionDetector,
|
||||
object_detector: RemoteObjectDetector,
|
||||
@@ -454,6 +469,8 @@ def process_frames(
|
||||
fps_tracker = EventsPerSecond()
|
||||
fps_tracker.start()
|
||||
|
||||
startup_scan_counter = 0
|
||||
|
||||
while not stop_event.is_set():
|
||||
if exit_on_empty and frame_queue.empty():
|
||||
logger.info(f"Exiting track_objects...")
|
||||
@@ -474,122 +491,219 @@ def process_frames(
|
||||
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)
|
||||
|
||||
# only get the tracked object boxes that intersect with motion
|
||||
tracked_object_boxes = [
|
||||
obj["box"]
|
||||
for obj in object_tracker.tracked_objects.values()
|
||||
if intersects_any(obj["box"], motion_boxes)
|
||||
]
|
||||
regions = []
|
||||
|
||||
# combine motion boxes with known locations of existing objects
|
||||
combined_boxes = reduce_boxes(motion_boxes + tracked_object_boxes)
|
||||
|
||||
# compute regions
|
||||
regions = [
|
||||
calculate_region(frame_shape, a[0], a[1], a[2], a[3], 1.2)
|
||||
for a in combined_boxes
|
||||
]
|
||||
|
||||
# combine overlapping regions
|
||||
combined_regions = reduce_boxes(regions)
|
||||
|
||||
# re-compute regions
|
||||
regions = [
|
||||
calculate_region(frame_shape, a[0], a[1], a[2], a[3], 1.0)
|
||||
for a in combined_regions
|
||||
]
|
||||
|
||||
# resize regions and detect
|
||||
detections = []
|
||||
for region in regions:
|
||||
detections.extend(
|
||||
detect(
|
||||
object_detector,
|
||||
frame,
|
||||
model_shape,
|
||||
region,
|
||||
objects_to_track,
|
||||
object_filters,
|
||||
# if detection is disabled
|
||||
if not detection_enabled.value:
|
||||
object_tracker.match_and_update(frame_time, [])
|
||||
else:
|
||||
# get stationary object ids
|
||||
# check every Nth frame for stationary objects
|
||||
# disappeared objects are not stationary
|
||||
# also check for overlapping motion boxes
|
||||
stationary_object_ids = [
|
||||
obj["id"]
|
||||
for obj in object_tracker.tracked_objects.values()
|
||||
# if there hasn't been motion for 10 frames
|
||||
if obj["motionless_count"] >= 10
|
||||
# and it isn't due for a periodic check
|
||||
and (
|
||||
detect_config.stationary_interval == 0
|
||||
or obj["motionless_count"] % detect_config.stationary_interval != 0
|
||||
)
|
||||
)
|
||||
# and it hasn't disappeared
|
||||
and object_tracker.disappeared[obj["id"]] == 0
|
||||
# and it doesn't overlap with any current motion boxes
|
||||
and not intersects_any(obj["box"], motion_boxes)
|
||||
]
|
||||
|
||||
#########
|
||||
# merge objects, check for clipped objects and look again up to 4 times
|
||||
#########
|
||||
refining = True
|
||||
refine_count = 0
|
||||
while refining and refine_count < 4:
|
||||
refining = False
|
||||
# get tracked object boxes that aren't stationary
|
||||
tracked_object_boxes = [
|
||||
obj["box"]
|
||||
for obj in object_tracker.tracked_objects.values()
|
||||
if not obj["id"] in stationary_object_ids
|
||||
]
|
||||
|
||||
# group by name
|
||||
detected_object_groups = defaultdict(lambda: [])
|
||||
for detection in detections:
|
||||
detected_object_groups[detection[0]].append(detection)
|
||||
# combine motion boxes with known locations of existing objects
|
||||
combined_boxes = reduce_boxes(motion_boxes + tracked_object_boxes)
|
||||
|
||||
selected_objects = []
|
||||
for group in detected_object_groups.values():
|
||||
region_min_size = max(model_shape[0], model_shape[1])
|
||||
# compute regions
|
||||
regions = [
|
||||
calculate_region(
|
||||
frame_shape,
|
||||
a[0],
|
||||
a[1],
|
||||
a[2],
|
||||
a[3],
|
||||
region_min_size,
|
||||
multiplier=random.uniform(1.2, 1.5),
|
||||
)
|
||||
for a in combined_boxes
|
||||
]
|
||||
|
||||
# apply non-maxima suppression to suppress weak, overlapping bounding boxes
|
||||
boxes = [
|
||||
(o[2][0], o[2][1], o[2][2] - o[2][0], o[2][3] - o[2][1])
|
||||
for o in group
|
||||
]
|
||||
confidences = [o[1] for o in group]
|
||||
idxs = cv2.dnn.NMSBoxes(boxes, confidences, 0.5, 0.4)
|
||||
# consolidate regions with heavy overlap
|
||||
regions = [
|
||||
calculate_region(
|
||||
frame_shape, a[0], a[1], a[2], a[3], region_min_size, multiplier=1.0
|
||||
)
|
||||
for a in reduce_boxes(regions, 0.4)
|
||||
]
|
||||
|
||||
for index in idxs:
|
||||
obj = group[index[0]]
|
||||
if clipped(obj, frame_shape):
|
||||
box = obj[2]
|
||||
# calculate a new region that will hopefully get the entire object
|
||||
region = calculate_region(
|
||||
frame_shape, box[0], box[1], box[2], box[3]
|
||||
)
|
||||
# if starting up, get the next startup scan region
|
||||
if startup_scan_counter < 9:
|
||||
ymin = int(frame_shape[0] / 3 * startup_scan_counter / 3)
|
||||
ymax = int(frame_shape[0] / 3 + ymin)
|
||||
xmin = int(frame_shape[1] / 3 * startup_scan_counter / 3)
|
||||
xmax = int(frame_shape[1] / 3 + xmin)
|
||||
regions.append(
|
||||
calculate_region(
|
||||
frame_shape,
|
||||
xmin,
|
||||
ymin,
|
||||
xmax,
|
||||
ymax,
|
||||
region_min_size,
|
||||
multiplier=1.2,
|
||||
)
|
||||
)
|
||||
startup_scan_counter += 1
|
||||
|
||||
regions.append(region)
|
||||
# resize regions and detect
|
||||
# seed with stationary objects
|
||||
detections = [
|
||||
(
|
||||
obj["label"],
|
||||
obj["score"],
|
||||
obj["box"],
|
||||
obj["area"],
|
||||
obj["region"],
|
||||
)
|
||||
for obj in object_tracker.tracked_objects.values()
|
||||
if obj["id"] in stationary_object_ids
|
||||
]
|
||||
|
||||
selected_objects.extend(
|
||||
detect(
|
||||
object_detector,
|
||||
frame,
|
||||
model_shape,
|
||||
region,
|
||||
objects_to_track,
|
||||
object_filters,
|
||||
for region in regions:
|
||||
detections.extend(
|
||||
detect(
|
||||
object_detector,
|
||||
frame,
|
||||
model_shape,
|
||||
region,
|
||||
objects_to_track,
|
||||
object_filters,
|
||||
)
|
||||
)
|
||||
|
||||
#########
|
||||
# merge objects, check for clipped objects and look again up to 4 times
|
||||
#########
|
||||
refining = len(regions) > 0
|
||||
refine_count = 0
|
||||
while refining and refine_count < 4:
|
||||
refining = False
|
||||
|
||||
# group by name
|
||||
detected_object_groups = defaultdict(lambda: [])
|
||||
for detection in detections:
|
||||
detected_object_groups[detection[0]].append(detection)
|
||||
|
||||
selected_objects = []
|
||||
for group in detected_object_groups.values():
|
||||
|
||||
# apply non-maxima suppression to suppress weak, overlapping bounding boxes
|
||||
boxes = [
|
||||
(o[2][0], o[2][1], o[2][2] - o[2][0], o[2][3] - o[2][1])
|
||||
for o in group
|
||||
]
|
||||
confidences = [o[1] for o in group]
|
||||
idxs = cv2.dnn.NMSBoxes(boxes, confidences, 0.5, 0.4)
|
||||
|
||||
for index in idxs:
|
||||
obj = group[index[0]]
|
||||
if clipped(obj, frame_shape):
|
||||
box = obj[2]
|
||||
# calculate a new region that will hopefully get the entire object
|
||||
region = calculate_region(
|
||||
frame_shape,
|
||||
box[0],
|
||||
box[1],
|
||||
box[2],
|
||||
box[3],
|
||||
region_min_size,
|
||||
)
|
||||
)
|
||||
|
||||
refining = True
|
||||
else:
|
||||
selected_objects.append(obj)
|
||||
# set the detections list to only include top, complete objects
|
||||
# and new detections
|
||||
detections = selected_objects
|
||||
regions.append(region)
|
||||
|
||||
if refining:
|
||||
refine_count += 1
|
||||
selected_objects.extend(
|
||||
detect(
|
||||
object_detector,
|
||||
frame,
|
||||
model_shape,
|
||||
region,
|
||||
objects_to_track,
|
||||
object_filters,
|
||||
)
|
||||
)
|
||||
|
||||
# Limit to the detections overlapping with motion areas
|
||||
# to avoid picking up stationary background objects
|
||||
detections_with_motion = [
|
||||
d for d in detections if intersects_any(d[2], motion_boxes)
|
||||
]
|
||||
refining = True
|
||||
else:
|
||||
selected_objects.append(obj)
|
||||
# set the detections list to only include top, complete objects
|
||||
# and new detections
|
||||
detections = selected_objects
|
||||
|
||||
# now that we have refined our detections, we need to track objects
|
||||
object_tracker.match_and_update(frame_time, detections_with_motion)
|
||||
if refining:
|
||||
refine_count += 1
|
||||
|
||||
## drop detections that overlap too much
|
||||
consolidated_detections = []
|
||||
|
||||
# if detection was run on this frame, consolidate
|
||||
if len(regions) > 0:
|
||||
# group by name
|
||||
detected_object_groups = defaultdict(lambda: [])
|
||||
for detection in detections:
|
||||
detected_object_groups[detection[0]].append(detection)
|
||||
|
||||
# loop over detections grouped by label
|
||||
for group in detected_object_groups.values():
|
||||
# if the group only has 1 item, skip
|
||||
if len(group) == 1:
|
||||
consolidated_detections.append(group[0])
|
||||
continue
|
||||
|
||||
# sort smallest to largest by area
|
||||
sorted_by_area = sorted(group, key=lambda g: g[3])
|
||||
|
||||
for current_detection_idx in range(0, len(sorted_by_area)):
|
||||
current_detection = sorted_by_area[current_detection_idx][2]
|
||||
overlap = 0
|
||||
for to_check_idx in range(
|
||||
min(current_detection_idx + 1, len(sorted_by_area)),
|
||||
len(sorted_by_area),
|
||||
):
|
||||
to_check = sorted_by_area[to_check_idx][2]
|
||||
# if 90% of smaller detection is inside of another detection, consolidate
|
||||
if (
|
||||
area(intersection(current_detection, to_check))
|
||||
/ area(current_detection)
|
||||
> 0.9
|
||||
):
|
||||
overlap = 1
|
||||
break
|
||||
if overlap == 0:
|
||||
consolidated_detections.append(
|
||||
sorted_by_area[current_detection_idx]
|
||||
)
|
||||
# now that we have refined our detections, we need to track objects
|
||||
object_tracker.match_and_update(frame_time, consolidated_detections)
|
||||
# else, just update the frame times for the stationary objects
|
||||
else:
|
||||
object_tracker.update_frame_times(frame_time)
|
||||
|
||||
# add to the queue if not full
|
||||
if detected_objects_queue.full():
|
||||
|
@@ -28,17 +28,19 @@ SQL = pw.SQL
|
||||
|
||||
|
||||
def migrate(migrator, database, fake=False, **kwargs):
|
||||
migrator.create_model(Recordings)
|
||||
|
||||
def add_index():
|
||||
# First add the index here, because there is a bug in peewee_migrate
|
||||
# when trying to create an multi-column index in the same migration
|
||||
# as the table: https://github.com/klen/peewee_migrate/issues/19
|
||||
Recordings.add_index("start_time", "end_time")
|
||||
Recordings.create_table()
|
||||
|
||||
migrator.python(add_index)
|
||||
migrator.sql(
|
||||
'CREATE TABLE IF NOT EXISTS "recordings" ("id" VARCHAR(30) NOT NULL PRIMARY KEY, "camera" VARCHAR(20) NOT NULL, "path" VARCHAR(255) NOT NULL, "start_time" DATETIME NOT NULL, "end_time" DATETIME NOT NULL, "duration" REAL NOT NULL)'
|
||||
)
|
||||
migrator.sql(
|
||||
'CREATE INDEX IF NOT EXISTS "recordings_camera" ON "recordings" ("camera")'
|
||||
)
|
||||
migrator.sql(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS "recordings_path" ON "recordings" ("path")'
|
||||
)
|
||||
migrator.sql(
|
||||
'CREATE INDEX IF NOT EXISTS "recordings_start_time_end_time" ON "recordings" (start_time, end_time)'
|
||||
)
|
||||
|
||||
|
||||
def rollback(migrator, database, fake=False, **kwargs):
|
||||
migrator.remove_model(Recordings)
|
||||
pass
|
||||
|
43
migrations/005_make_end_time_nullable.py
Normal file
43
migrations/005_make_end_time_nullable.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Peewee migrations -- 004_add_bbox_region_area.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['model_name'] # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.python(func, *args, **kwargs) # Run python code
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
import peewee as pw
|
||||
from playhouse.sqlite_ext import *
|
||||
from decimal import ROUND_HALF_EVEN
|
||||
from frigate.models import Event
|
||||
|
||||
try:
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
SQL = pw.SQL
|
||||
|
||||
|
||||
def migrate(migrator, database, fake=False, **kwargs):
|
||||
migrator.drop_not_null(Event, "end_time")
|
||||
|
||||
|
||||
def rollback(migrator, database, fake=False, **kwargs):
|
||||
pass
|
47
migrations/006_add_motion_active_objects.py
Normal file
47
migrations/006_add_motion_active_objects.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Peewee migrations -- 004_add_bbox_region_area.py.
|
||||
|
||||
Some examples (model - class or model name)::
|
||||
|
||||
> Model = migrator.orm['model_name'] # Return model in current state by name
|
||||
|
||||
> migrator.sql(sql) # Run custom SQL
|
||||
> migrator.python(func, *args, **kwargs) # Run python code
|
||||
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||
> migrator.change_fields(model, **fields) # Change fields
|
||||
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||
> migrator.rename_table(model, new_table_name)
|
||||
> migrator.add_index(model, *col_names, unique=False)
|
||||
> migrator.drop_index(model, *col_names)
|
||||
> migrator.add_not_null(model, *field_names)
|
||||
> migrator.drop_not_null(model, *field_names)
|
||||
> migrator.add_default(model, field_name, default)
|
||||
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
import peewee as pw
|
||||
from playhouse.sqlite_ext import *
|
||||
from decimal import ROUND_HALF_EVEN
|
||||
from frigate.models import Recordings
|
||||
|
||||
try:
|
||||
import playhouse.postgres_ext as pw_pext
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
SQL = pw.SQL
|
||||
|
||||
|
||||
def migrate(migrator, database, fake=False, **kwargs):
|
||||
migrator.add_fields(
|
||||
Recordings,
|
||||
objects=pw.IntegerField(null=True),
|
||||
motion=pw.IntegerField(null=True),
|
||||
)
|
||||
|
||||
|
||||
def rollback(migrator, database, fake=False, **kwargs):
|
||||
migrator.remove_fields(Recordings, ["objects", "motion"])
|
@@ -1,23 +1,26 @@
|
||||
import datetime
|
||||
import sys
|
||||
from typing_extensions import runtime
|
||||
|
||||
sys.path.append("/lab/frigate")
|
||||
|
||||
import json
|
||||
import logging
|
||||
import multiprocessing as mp
|
||||
import os
|
||||
import subprocess as sp
|
||||
import sys
|
||||
from unittest import TestCase, main
|
||||
|
||||
import click
|
||||
import csv
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from frigate.config import FRIGATE_CONFIG_SCHEMA, FrigateConfig
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.edgetpu import LocalObjectDetector
|
||||
from frigate.motion import MotionDetector
|
||||
from frigate.object_processing import CameraState
|
||||
from frigate.objects import ObjectTracker
|
||||
from frigate.util import (
|
||||
DictFrameManager,
|
||||
EventsPerSecond,
|
||||
SharedMemoryFrameManager,
|
||||
draw_box_with_label,
|
||||
@@ -96,20 +99,22 @@ class ProcessClip:
|
||||
ffmpeg_process.wait()
|
||||
ffmpeg_process.communicate()
|
||||
|
||||
def process_frames(self, objects_to_track=["person"], object_filters={}):
|
||||
def process_frames(
|
||||
self, object_detector, objects_to_track=["person"], object_filters={}
|
||||
):
|
||||
mask = np.zeros((self.frame_shape[0], self.frame_shape[1], 1), np.uint8)
|
||||
mask[:] = 255
|
||||
motion_detector = MotionDetector(
|
||||
self.frame_shape, mask, self.camera_config.motion
|
||||
)
|
||||
motion_detector = MotionDetector(self.frame_shape, self.camera_config.motion)
|
||||
motion_detector.save_images = False
|
||||
|
||||
object_detector = LocalObjectDetector(labels="/labelmap.txt")
|
||||
object_tracker = ObjectTracker(self.camera_config.detect)
|
||||
process_info = {
|
||||
"process_fps": mp.Value("d", 0.0),
|
||||
"detection_fps": mp.Value("d", 0.0),
|
||||
"detection_frame": mp.Value("d", 0.0),
|
||||
}
|
||||
|
||||
detection_enabled = mp.Value("d", 1)
|
||||
stop_event = mp.Event()
|
||||
model_shape = (self.config.model.height, self.config.model.width)
|
||||
|
||||
@@ -118,6 +123,7 @@ class ProcessClip:
|
||||
self.frame_queue,
|
||||
self.frame_shape,
|
||||
model_shape,
|
||||
self.camera_config.detect,
|
||||
self.frame_manager,
|
||||
motion_detector,
|
||||
object_detector,
|
||||
@@ -126,25 +132,16 @@ class ProcessClip:
|
||||
process_info,
|
||||
objects_to_track,
|
||||
object_filters,
|
||||
mask,
|
||||
detection_enabled,
|
||||
stop_event,
|
||||
exit_on_empty=True,
|
||||
)
|
||||
|
||||
def top_object(self, debug_path=None):
|
||||
obj_detected = False
|
||||
top_computed_score = 0.0
|
||||
|
||||
def handle_event(name, obj, frame_time):
|
||||
nonlocal obj_detected
|
||||
nonlocal top_computed_score
|
||||
if obj.computed_score > top_computed_score:
|
||||
top_computed_score = obj.computed_score
|
||||
if not obj.false_positive:
|
||||
obj_detected = True
|
||||
|
||||
self.camera_state.on("new", handle_event)
|
||||
self.camera_state.on("update", handle_event)
|
||||
def stats(self, debug_path=None):
|
||||
total_regions = 0
|
||||
total_motion_boxes = 0
|
||||
object_ids = set()
|
||||
total_frames = 0
|
||||
|
||||
while not self.detected_objects_queue.empty():
|
||||
(
|
||||
@@ -154,7 +151,8 @@ class ProcessClip:
|
||||
motion_boxes,
|
||||
regions,
|
||||
) = self.detected_objects_queue.get()
|
||||
if not debug_path is None:
|
||||
|
||||
if debug_path:
|
||||
self.save_debug_frame(
|
||||
debug_path, frame_time, current_tracked_objects.values()
|
||||
)
|
||||
@@ -162,10 +160,26 @@ class ProcessClip:
|
||||
self.camera_state.update(
|
||||
frame_time, current_tracked_objects, motion_boxes, regions
|
||||
)
|
||||
total_regions += len(regions)
|
||||
total_motion_boxes += len(motion_boxes)
|
||||
top_score = 0
|
||||
for id, obj in self.camera_state.tracked_objects.items():
|
||||
if not obj.false_positive:
|
||||
object_ids.add(id)
|
||||
if obj.top_score > top_score:
|
||||
top_score = obj.top_score
|
||||
|
||||
self.frame_manager.delete(self.camera_state.previous_frame_id)
|
||||
total_frames += 1
|
||||
|
||||
return {"object_detected": obj_detected, "top_score": top_computed_score}
|
||||
self.frame_manager.delete(self.camera_state.previous_frame_id)
|
||||
|
||||
return {
|
||||
"total_regions": total_regions,
|
||||
"total_motion_boxes": total_motion_boxes,
|
||||
"true_positive_objects": len(object_ids),
|
||||
"total_frames": total_frames,
|
||||
"top_score": top_score,
|
||||
}
|
||||
|
||||
def save_debug_frame(self, debug_path, frame_time, tracked_objects):
|
||||
current_frame = cv2.cvtColor(
|
||||
@@ -178,7 +192,6 @@ class ProcessClip:
|
||||
for obj in tracked_objects:
|
||||
thickness = 2
|
||||
color = (0, 0, 175)
|
||||
|
||||
if obj["frame_time"] != frame_time:
|
||||
thickness = 1
|
||||
color = (255, 0, 0)
|
||||
@@ -221,10 +234,9 @@ class ProcessClip:
|
||||
@click.command()
|
||||
@click.option("-p", "--path", required=True, help="Path to clip or directory to test.")
|
||||
@click.option("-l", "--label", default="person", help="Label name to detect.")
|
||||
@click.option("-t", "--threshold", default=0.85, help="Threshold value for objects.")
|
||||
@click.option("-s", "--scores", default=None, help="File to save csv of top scores")
|
||||
@click.option("-o", "--output", default=None, help="File to save csv of data")
|
||||
@click.option("--debug-path", default=None, help="Path to output frames for debugging.")
|
||||
def process(path, label, threshold, scores, debug_path):
|
||||
def process(path, label, output, debug_path):
|
||||
clips = []
|
||||
if os.path.isdir(path):
|
||||
files = os.listdir(path)
|
||||
@@ -235,51 +247,79 @@ def process(path, label, threshold, scores, debug_path):
|
||||
|
||||
json_config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"detectors": {"coral": {"type": "edgetpu", "device": "usb"}},
|
||||
"cameras": {
|
||||
"camera": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{
|
||||
"path": "path.mp4",
|
||||
"global_args": "",
|
||||
"input_args": "",
|
||||
"global_args": "-hide_banner",
|
||||
"input_args": "-loglevel info",
|
||||
"roles": ["detect"],
|
||||
}
|
||||
]
|
||||
},
|
||||
"height": 1920,
|
||||
"width": 1080,
|
||||
"rtmp": {"enabled": False},
|
||||
"record": {"enabled": False},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
object_detector = LocalObjectDetector(labels="/labelmap.txt")
|
||||
|
||||
results = []
|
||||
for c in clips:
|
||||
logger.info(c)
|
||||
frame_shape = get_frame_shape(c)
|
||||
|
||||
json_config["cameras"]["camera"]["height"] = frame_shape[0]
|
||||
json_config["cameras"]["camera"]["width"] = frame_shape[1]
|
||||
json_config["cameras"]["camera"]["detect"] = {
|
||||
"height": frame_shape[0],
|
||||
"width": frame_shape[1],
|
||||
}
|
||||
json_config["cameras"]["camera"]["ffmpeg"]["inputs"][0]["path"] = c
|
||||
|
||||
config = FrigateConfig(config=FRIGATE_CONFIG_SCHEMA(json_config))
|
||||
frigate_config = FrigateConfig(**json_config)
|
||||
runtime_config = frigate_config.runtime_config
|
||||
runtime_config.cameras["camera"].create_ffmpeg_cmds()
|
||||
|
||||
process_clip = ProcessClip(c, frame_shape, config)
|
||||
process_clip = ProcessClip(c, frame_shape, runtime_config)
|
||||
process_clip.load_frames()
|
||||
process_clip.process_frames(objects_to_track=[label])
|
||||
process_clip.process_frames(object_detector, objects_to_track=[label])
|
||||
|
||||
results.append((c, process_clip.top_object(debug_path)))
|
||||
results.append((c, process_clip.stats(debug_path)))
|
||||
|
||||
if not scores is None:
|
||||
with open(scores, "w") as writer:
|
||||
for result in results:
|
||||
writer.write(f"{result[0]},{result[1]['top_score']}\n")
|
||||
|
||||
positive_count = sum(1 for result in results if result[1]["object_detected"])
|
||||
positive_count = sum(
|
||||
1 for result in results if result[1]["true_positive_objects"] > 0
|
||||
)
|
||||
print(
|
||||
f"Objects were detected in {positive_count}/{len(results)}({positive_count/len(results)*100:.2f}%) clip(s)."
|
||||
)
|
||||
|
||||
if output:
|
||||
# now we will open a file for writing
|
||||
data_file = open(output, "w")
|
||||
|
||||
# create the csv writer object
|
||||
csv_writer = csv.writer(data_file)
|
||||
|
||||
# Counter variable used for writing
|
||||
# headers to the CSV file
|
||||
count = 0
|
||||
|
||||
for result in results:
|
||||
if count == 0:
|
||||
|
||||
# Writing headers of CSV file
|
||||
header = ["file"] + list(result[1].keys())
|
||||
csv_writer.writerow(header)
|
||||
count += 1
|
||||
|
||||
# Writing data of CSV file
|
||||
csv_writer.writerow([result[0]] + list(result[1].values()))
|
||||
|
||||
data_file.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
process()
|
14794
web/package-lock.json
generated
14794
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -121,12 +121,12 @@ describe('MqttProvider', () => {
|
||||
</MqttProvider>
|
||||
);
|
||||
await screen.findByTestId('data');
|
||||
expect(screen.getByTestId('front/detect/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"ON"}');
|
||||
expect(screen.getByTestId('front/recordings/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF"}');
|
||||
expect(screen.getByTestId('front/snapshots/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"ON"}');
|
||||
expect(screen.getByTestId('side/detect/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF"}');
|
||||
expect(screen.getByTestId('side/recordings/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF"}');
|
||||
expect(screen.getByTestId('side/snapshots/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF"}');
|
||||
expect(screen.getByTestId('front/detect/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"ON","retain":true}');
|
||||
expect(screen.getByTestId('front/recordings/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF","retain":true}');
|
||||
expect(screen.getByTestId('front/snapshots/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"ON","retain":true}');
|
||||
expect(screen.getByTestId('side/detect/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF","retain":true}');
|
||||
expect(screen.getByTestId('side/recordings/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF","retain":true}');
|
||||
expect(screen.getByTestId('side/snapshots/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF","retain":true}');
|
||||
});
|
||||
});
|
||||
|
||||
|
@@ -42,9 +42,9 @@ export function MqttProvider({
|
||||
useEffect(() => {
|
||||
Object.keys(config.cameras).forEach((camera) => {
|
||||
const { name, record, detect, snapshots } = config.cameras[camera];
|
||||
dispatch({ topic: `${name}/recordings/state`, payload: record.enabled ? 'ON' : 'OFF' });
|
||||
dispatch({ topic: `${name}/detect/state`, payload: detect.enabled ? 'ON' : 'OFF' });
|
||||
dispatch({ topic: `${name}/snapshots/state`, payload: snapshots.enabled ? 'ON' : 'OFF' });
|
||||
dispatch({ topic: `${name}/recordings/state`, payload: record.enabled ? 'ON' : 'OFF', retain: true });
|
||||
dispatch({ topic: `${name}/detect/state`, payload: detect.enabled ? 'ON' : 'OFF', retain: true });
|
||||
dispatch({ topic: `${name}/snapshots/state`, payload: snapshots.enabled ? 'ON' : 'OFF', retain: true });
|
||||
});
|
||||
}, [config]);
|
||||
|
||||
|
329
web/src/components/Calender.jsx
Normal file
329
web/src/components/Calender.jsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import { h } from 'preact';
|
||||
import { useEffect, useState, useCallback, useMemo, useRef } from 'preact/hooks';
|
||||
import ArrowRight from '../icons/ArrowRight';
|
||||
import ArrowRightDouble from '../icons/ArrowRightDouble';
|
||||
|
||||
const todayTimestamp = new Date().setHours(0, 0, 0, 0).valueOf();
|
||||
|
||||
const Calender = ({ onChange, calenderRef, close }) => {
|
||||
const keyRef = useRef([]);
|
||||
|
||||
const date = new Date();
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth();
|
||||
|
||||
const daysMap = useMemo(() => ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'], []);
|
||||
const monthMap = useMemo(
|
||||
() => [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const [state, setState] = useState({
|
||||
getMonthDetails: [],
|
||||
year,
|
||||
month,
|
||||
selectedDay: null,
|
||||
timeRange: { before: null, after: null },
|
||||
monthDetails: null,
|
||||
});
|
||||
|
||||
const getNumberOfDays = useCallback((year, month) => {
|
||||
return 40 - new Date(year, month, 40).getDate();
|
||||
}, []);
|
||||
|
||||
const getDayDetails = useCallback(
|
||||
(args) => {
|
||||
const date = args.index - args.firstDay;
|
||||
const day = args.index % 7;
|
||||
let prevMonth = args.month - 1;
|
||||
let prevYear = args.year;
|
||||
if (prevMonth < 0) {
|
||||
prevMonth = 11;
|
||||
prevYear--;
|
||||
}
|
||||
const prevMonthNumberOfDays = getNumberOfDays(prevYear, prevMonth);
|
||||
const _date = (date < 0 ? prevMonthNumberOfDays + date : date % args.numberOfDays) + 1;
|
||||
const month = date < 0 ? -1 : date >= args.numberOfDays ? 1 : 0;
|
||||
const timestamp = new Date(args.year, args.month, _date).getTime();
|
||||
return {
|
||||
date: _date,
|
||||
day,
|
||||
month,
|
||||
timestamp,
|
||||
dayString: daysMap[day],
|
||||
};
|
||||
},
|
||||
[getNumberOfDays, daysMap]
|
||||
);
|
||||
|
||||
const getMonthDetails = useCallback(
|
||||
(year, month) => {
|
||||
const firstDay = new Date(year, month).getDay();
|
||||
const numberOfDays = getNumberOfDays(year, month);
|
||||
const monthArray = [];
|
||||
const rows = 6;
|
||||
let currentDay = null;
|
||||
let index = 0;
|
||||
const cols = 7;
|
||||
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
currentDay = getDayDetails({
|
||||
index,
|
||||
numberOfDays,
|
||||
firstDay,
|
||||
year,
|
||||
month,
|
||||
});
|
||||
monthArray.push(currentDay);
|
||||
index++;
|
||||
}
|
||||
}
|
||||
return monthArray;
|
||||
},
|
||||
[getNumberOfDays, getDayDetails]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setState((prev) => ({ ...prev, selectedDay: todayTimestamp, monthDetails: getMonthDetails(year, month) }));
|
||||
}, [year, month, getMonthDetails]);
|
||||
|
||||
useEffect(() => {
|
||||
// add refs for keyboard navigation
|
||||
if (state.monthDetails) {
|
||||
keyRef.current = keyRef.current.slice(0, state.monthDetails.length);
|
||||
}
|
||||
// set today date in focus for keyboard navigation
|
||||
const todayDate = new Date(todayTimestamp).getDate();
|
||||
keyRef.current.find((t) => t.tabIndex === todayDate)?.focus();
|
||||
}, [state.monthDetails]);
|
||||
|
||||
const isCurrentDay = (day) => day.timestamp === todayTimestamp;
|
||||
|
||||
const isSelectedRange = useCallback(
|
||||
(day) => {
|
||||
if (!state.timeRange.after || !state.timeRange.before) return;
|
||||
|
||||
return day.timestamp < state.timeRange.before && day.timestamp >= state.timeRange.after;
|
||||
},
|
||||
[state.timeRange]
|
||||
);
|
||||
|
||||
const isFirstDayInRange = useCallback(
|
||||
(day) => {
|
||||
if (isCurrentDay(day)) return;
|
||||
return state.timeRange.after === day.timestamp;
|
||||
},
|
||||
[state.timeRange.after]
|
||||
);
|
||||
|
||||
const isLastDayInRange = useCallback(
|
||||
(day) => {
|
||||
return state.timeRange.before === new Date(day.timestamp).setHours(24, 0, 0, 0);
|
||||
},
|
||||
[state.timeRange.before]
|
||||
);
|
||||
|
||||
const getMonthStr = useCallback(
|
||||
(month) => {
|
||||
return monthMap[Math.max(Math.min(11, month), 0)] || 'Month';
|
||||
},
|
||||
[monthMap]
|
||||
);
|
||||
|
||||
const onDateClick = (day) => {
|
||||
const { before, after } = state.timeRange;
|
||||
let timeRange = { before: null, after: null };
|
||||
|
||||
// user has selected a date < after, reset values
|
||||
if (after === null || day.timestamp < after) {
|
||||
timeRange = { before: new Date(day.timestamp).setHours(24, 0, 0, 0), after: day.timestamp };
|
||||
}
|
||||
|
||||
// user has selected a date > after
|
||||
if (after !== null && before !== new Date(day.timestamp).setHours(24, 0, 0, 0) && day.timestamp > after) {
|
||||
timeRange = {
|
||||
after,
|
||||
before:
|
||||
day.timestamp >= todayTimestamp
|
||||
? new Date(todayTimestamp).setHours(24, 0, 0, 0)
|
||||
: new Date(day.timestamp).setHours(24, 0, 0, 0),
|
||||
};
|
||||
}
|
||||
|
||||
// reset values
|
||||
if (before === new Date(day.timestamp).setHours(24, 0, 0, 0)) {
|
||||
timeRange = { before: null, after: null };
|
||||
}
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
timeRange,
|
||||
selectedDay: day.timestamp,
|
||||
}));
|
||||
|
||||
if (onChange) {
|
||||
onChange(timeRange.after ? { before: timeRange.before / 1000, after: timeRange.after / 1000 } : ['all']);
|
||||
}
|
||||
};
|
||||
|
||||
const setYear = useCallback(
|
||||
(offset) => {
|
||||
const year = state.year + offset;
|
||||
const month = state.month;
|
||||
setState((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
year,
|
||||
monthDetails: getMonthDetails(year, month),
|
||||
};
|
||||
});
|
||||
},
|
||||
[state.year, state.month, getMonthDetails]
|
||||
);
|
||||
|
||||
const setMonth = (offset) => {
|
||||
let year = state.year;
|
||||
let month = state.month + offset;
|
||||
if (month === -1) {
|
||||
month = 11;
|
||||
year--;
|
||||
} else if (month === 12) {
|
||||
month = 0;
|
||||
year++;
|
||||
}
|
||||
setState((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
year,
|
||||
month,
|
||||
monthDetails: getMonthDetails(year, month),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeydown = (e, day, index) => {
|
||||
if ((keyRef.current && e.key === 'Enter') || e.keyCode === 32) {
|
||||
e.preventDefault();
|
||||
day.month === 0 && onDateClick(day);
|
||||
}
|
||||
if (e.key === 'ArrowLeft') {
|
||||
index > 0 && keyRef.current[index - 1].focus();
|
||||
}
|
||||
if (e.key === 'ArrowRight') {
|
||||
index < 41 && keyRef.current[index + 1].focus();
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
index > 6 && keyRef.current[index - 7].focus();
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
index < 36 && keyRef.current[index + 7].focus();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
const renderCalendar = () => {
|
||||
const days =
|
||||
state.monthDetails &&
|
||||
state.monthDetails.map((day, idx) => {
|
||||
return (
|
||||
<div
|
||||
onClick={() => onDateClick(day)}
|
||||
onkeydown={(e) => handleKeydown(e, day, idx)}
|
||||
ref={(ref) => (keyRef.current[idx] = ref)}
|
||||
tabIndex={day.month === 0 ? day.date : null}
|
||||
className={`h-12 w-12 float-left flex flex-shrink justify-center items-center cursor-pointer ${
|
||||
day.month !== 0 ? ' opacity-50 bg-gray-700 dark:bg-gray-700 pointer-events-none' : ''
|
||||
}
|
||||
${isFirstDayInRange(day) ? ' rounded-l-xl ' : ''}
|
||||
${isSelectedRange(day) ? ' bg-blue-600 dark:hover:bg-blue-600' : ''}
|
||||
${isLastDayInRange(day) ? ' rounded-r-xl ' : ''}
|
||||
${isCurrentDay(day) && !isLastDayInRange(day) ? 'rounded-full bg-gray-100 dark:hover:bg-gray-100 ' : ''}`}
|
||||
key={idx}
|
||||
>
|
||||
<div className="font-light">
|
||||
<span className="text-gray-400">{day.date}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="w-full flex justify-start flex-shrink">
|
||||
{['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'].map((d, i) => (
|
||||
<div key={i} className="w-12 text-xs font-light text-center">
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-full h-56">{days}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="select-none w-96 flex flex-shrink" ref={calenderRef}>
|
||||
<div className="py-4 px-6">
|
||||
<div className="flex items-center">
|
||||
<div className="w-1/6 relative flex justify-around">
|
||||
<div
|
||||
tabIndex={100}
|
||||
className="flex justify-center items-center cursor-pointer absolute -mt-4 text-center rounded-full w-10 h-10 bg-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800"
|
||||
onClick={() => setYear(-1)}
|
||||
>
|
||||
<ArrowRightDouble className="h-2/6 transform rotate-180 " />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1/6 relative flex justify-around ">
|
||||
<div
|
||||
tabIndex={101}
|
||||
className="flex justify-center items-center cursor-pointer absolute -mt-4 text-center rounded-full w-10 h-10 bg-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800"
|
||||
onClick={() => setMonth(-1)}
|
||||
>
|
||||
<ArrowRight className="h-2/6 transform rotate-180 red" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1/3">
|
||||
<div className="text-3xl text-center text-gray-200 font-extralight">{state.year}</div>
|
||||
<div className="text-center text-gray-400 font-extralight">{getMonthStr(state.month)}</div>
|
||||
</div>
|
||||
<div className="w-1/6 relative flex justify-around ">
|
||||
<div
|
||||
tabIndex={102}
|
||||
className="flex justify-center items-center cursor-pointer absolute -mt-4 text-center rounded-full w-10 h-10 bg-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800"
|
||||
onClick={() => setMonth(1)}
|
||||
>
|
||||
<ArrowRight className="h-2/6" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1/6 relative flex justify-around " tabIndex={104} onClick={() => setYear(1)}>
|
||||
<div className="flex justify-center items-center cursor-pointer absolute -mt-4 text-center rounded-full w-10 h-10 bg-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800">
|
||||
<ArrowRightDouble className="h-2/6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">{renderCalendar()}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Calender;
|
162
web/src/components/DatePicker.jsx
Normal file
162
web/src/components/DatePicker.jsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { h } from 'preact';
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks';
|
||||
|
||||
export const DateFilterOptions = [
|
||||
{
|
||||
label: 'All',
|
||||
value: ['all'],
|
||||
},
|
||||
{
|
||||
label: 'Today',
|
||||
value: {
|
||||
//Before
|
||||
before: new Date().setHours(24, 0, 0, 0) / 1000,
|
||||
//After
|
||||
after: new Date().setHours(0, 0, 0, 0) / 1000,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Yesterday',
|
||||
value: {
|
||||
//Before
|
||||
before: new Date(new Date().setDate(new Date().getDate() - 1)).setHours(24, 0, 0, 0) / 1000,
|
||||
//After
|
||||
after: new Date(new Date().setDate(new Date().getDate() - 1)).setHours(0, 0, 0, 0) / 1000,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Last 7 Days',
|
||||
value: {
|
||||
//Before
|
||||
before: new Date().setHours(24, 0, 0, 0) / 1000,
|
||||
//After
|
||||
after: new Date(new Date().setDate(new Date().getDate() - 7)).setHours(0, 0, 0, 0) / 1000,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'This Month',
|
||||
value: {
|
||||
//Before
|
||||
before: new Date().setHours(24, 0, 0, 0) / 1000,
|
||||
//After
|
||||
after: new Date(new Date().getFullYear(), new Date().getMonth(), 1).getTime() / 1000,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Last Month',
|
||||
value: {
|
||||
//Before
|
||||
before: new Date(new Date().getFullYear(), new Date().getMonth(), 1).getTime() / 1000,
|
||||
//After
|
||||
after: new Date(new Date().getFullYear(), new Date().getMonth() - 1, 1).getTime() / 1000,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Custom Range',
|
||||
value: 'custom_range',
|
||||
},
|
||||
];
|
||||
|
||||
export default function DatePicker({
|
||||
helpText,
|
||||
keyboardType = 'text',
|
||||
inputRef,
|
||||
label,
|
||||
leadingIcon: LeadingIcon,
|
||||
onBlur,
|
||||
onChangeText,
|
||||
onFocus,
|
||||
readonly,
|
||||
trailingIcon: TrailingIcon,
|
||||
value: propValue = '',
|
||||
...props
|
||||
}) {
|
||||
const [isFocused, setFocused] = useState(false);
|
||||
const [value, setValue] = useState(propValue);
|
||||
|
||||
useEffect(() => {
|
||||
if (propValue !== value) {
|
||||
setValue(propValue);
|
||||
}
|
||||
}, [propValue, setValue, value]);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(event) => {
|
||||
setFocused(true);
|
||||
onFocus && onFocus(event);
|
||||
},
|
||||
[onFocus]
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(event) => {
|
||||
setFocused(false);
|
||||
onBlur && onBlur(event);
|
||||
},
|
||||
[onBlur]
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(event) => {
|
||||
const { value } = event.target;
|
||||
setValue(value);
|
||||
onChangeText && onChangeText(value);
|
||||
},
|
||||
[onChangeText, setValue]
|
||||
);
|
||||
|
||||
const onClick = (e) => {
|
||||
props.onclick(e);
|
||||
};
|
||||
const labelMoved = isFocused || value !== '';
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{props.children}
|
||||
<div
|
||||
className={`bg-gray-100 dark:bg-gray-700 rounded rounded-b-none border-gray-400 border-b p-1 pl-4 pr-3 ${
|
||||
isFocused ? 'border-blue-500 dark:border-blue-500' : ''
|
||||
}`}
|
||||
ref={inputRef}
|
||||
>
|
||||
<label
|
||||
className="flex space-x-2 items-center"
|
||||
data-testid={`label-${label.toLowerCase().replace(/[^\w]+/g, '_')}`}
|
||||
>
|
||||
{LeadingIcon ? (
|
||||
<div className="w-10 h-full">
|
||||
<LeadingIcon />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
className="h-6 mt-6 w-full bg-transparent focus:outline-none focus:ring-0"
|
||||
type={keyboardType}
|
||||
readOnly
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onInput={handleChange}
|
||||
tabIndex="0"
|
||||
onClick={onClick}
|
||||
value={propValue}
|
||||
{...props}
|
||||
/>
|
||||
<div
|
||||
className={`absolute top-3 transition transform text-gray-600 dark:text-gray-400 ${
|
||||
labelMoved ? 'text-xs -translate-y-2' : ''
|
||||
} ${isFocused ? 'text-blue-500 dark:text-blue-500' : ''}`}
|
||||
>
|
||||
<p>{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
{TrailingIcon ? (
|
||||
<div className="w-10 h-10">
|
||||
<TrailingIcon />
|
||||
</div>
|
||||
) : null}
|
||||
</label>
|
||||
</div>
|
||||
{helpText ? <div className="text-xs pl-3 pt-1">{helpText}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,6 +1,14 @@
|
||||
import { h } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { addSeconds, differenceInSeconds, fromUnixTime, format, parseISO, startOfHour } from 'date-fns';
|
||||
import {
|
||||
differenceInSeconds,
|
||||
fromUnixTime,
|
||||
format,
|
||||
parseISO,
|
||||
startOfHour,
|
||||
differenceInMinutes,
|
||||
differenceInHours,
|
||||
} from 'date-fns';
|
||||
import ArrowDropdown from '../icons/ArrowDropdown';
|
||||
import ArrowDropup from '../icons/ArrowDropup';
|
||||
import Link from '../components/Link';
|
||||
@@ -21,25 +29,31 @@ export default function RecordingPlaylist({ camera, recordings, selectedDate, se
|
||||
events={recording.events}
|
||||
selected={recording.date === selectedDate}
|
||||
>
|
||||
{recording.recordings.slice().reverse().map((item, i) => (
|
||||
<div className="mb-2 w-full">
|
||||
<div
|
||||
className={`flex w-full text-md text-white px-8 py-2 mb-2 ${
|
||||
i === 0 ? 'border-t border-white border-opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<Link href={`/recording/${camera}/${recording.date}/${item.hour}`} type="text">
|
||||
{item.hour}:00
|
||||
</Link>
|
||||
{recording.recordings
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((item, i) => (
|
||||
<div className="mb-2 w-full">
|
||||
<div
|
||||
className={`flex w-full text-md text-white px-8 py-2 mb-2 ${
|
||||
i === 0 ? 'border-t border-white border-opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<Link href={`/recording/${camera}/${recording.date}/${item.hour}`} type="text">
|
||||
{item.hour}:00
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex-1 text-right">{item.events.length} Events</div>
|
||||
</div>
|
||||
<div className="flex-1 text-right">{item.events.length} Events</div>
|
||||
{item.events
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((event) => (
|
||||
<EventCard camera={camera} event={event} delay={item.delay} />
|
||||
))}
|
||||
</div>
|
||||
{item.events.slice().reverse().map((event) => (
|
||||
<EventCard camera={camera} event={event} delay={item.delay} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</ExpandableList>
|
||||
);
|
||||
}
|
||||
@@ -83,8 +97,17 @@ export function ExpandableList({ title, events = 0, children, selected = false }
|
||||
export function EventCard({ camera, event, delay }) {
|
||||
const apiHost = useApiHost();
|
||||
const start = fromUnixTime(event.start_time);
|
||||
const end = fromUnixTime(event.end_time);
|
||||
const duration = addSeconds(new Date(0), differenceInSeconds(end, start));
|
||||
let duration = 'In Progress';
|
||||
if (event.end_time) {
|
||||
const end = fromUnixTime(event.end_time);
|
||||
const hours = differenceInHours(end, start);
|
||||
const minutes = differenceInMinutes(end, start) - hours * 60;
|
||||
const seconds = differenceInSeconds(end, start) - hours * 60 - minutes * 60;
|
||||
duration = '';
|
||||
if (hours) duration += `${hours}h `;
|
||||
if (minutes) duration += `${minutes}m `;
|
||||
duration += `${seconds}s`;
|
||||
}
|
||||
const position = differenceInSeconds(start, startOfHour(start));
|
||||
const offset = Object.entries(delay)
|
||||
.map(([p, d]) => (position > p ? d : 0))
|
||||
@@ -102,7 +125,7 @@ export function EventCard({ camera, event, delay }) {
|
||||
<div className="flex-1">
|
||||
<div className="text-2xl text-white leading-tight capitalize">{event.label}</div>
|
||||
<div className="text-xs md:text-normal text-gray-300">Start: {format(start, 'HH:mm:ss')}</div>
|
||||
<div className="text-xs md:text-normal text-gray-300">Duration: {format(duration, 'mm:ss')}</div>
|
||||
<div className="text-xs md:text-normal text-gray-300">Duration: {duration}</div>
|
||||
</div>
|
||||
<div className="text-lg text-white text-right leading-tight">{(event.top_score * 100).toFixed(1)}%</div>
|
||||
</div>
|
||||
|
@@ -27,7 +27,7 @@ export default function RelativeModal({
|
||||
|
||||
const handleKeydown = useCallback(
|
||||
(event) => {
|
||||
const focusable = ref.current.querySelectorAll('[tabindex]');
|
||||
const focusable = ref.current && ref.current.querySelectorAll('[tabindex]');
|
||||
if (event.key === 'Tab' && focusable.length) {
|
||||
if (event.shiftKey && document.activeElement === focusable[0]) {
|
||||
focusable[focusable.length - 1].focus();
|
||||
@@ -69,14 +69,15 @@ export default function RelativeModal({
|
||||
let newTop = top;
|
||||
let newLeft = left;
|
||||
|
||||
// too far right
|
||||
if (newLeft + width + WINDOW_PADDING >= windowWidth - WINDOW_PADDING) {
|
||||
newLeft = windowWidth - width - WINDOW_PADDING;
|
||||
}
|
||||
// too far left
|
||||
else if (left < WINDOW_PADDING) {
|
||||
if (left < WINDOW_PADDING) {
|
||||
newLeft = WINDOW_PADDING;
|
||||
}
|
||||
// too far right
|
||||
else if (newLeft + width + WINDOW_PADDING >= windowWidth - WINDOW_PADDING) {
|
||||
newLeft = windowWidth - width - WINDOW_PADDING;
|
||||
}
|
||||
|
||||
// too close to bottom
|
||||
if (top + menuHeight > windowHeight - WINDOW_PADDING + window.scrollY) {
|
||||
newTop = WINDOW_PADDING;
|
||||
|
@@ -3,74 +3,27 @@ import ArrowDropdown from '../icons/ArrowDropdown';
|
||||
import ArrowDropup from '../icons/ArrowDropup';
|
||||
import Menu, { MenuItem } from './Menu';
|
||||
import TextField from './TextField';
|
||||
import DatePicker from './DatePicker';
|
||||
import Calender from './Calender';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
|
||||
export default function Select({ label, onChange, options: inputOptions = [], selected: propSelected }) {
|
||||
export default function Select({
|
||||
type,
|
||||
label,
|
||||
onChange,
|
||||
paramName,
|
||||
options: inputOptions = [],
|
||||
selected: propSelected,
|
||||
}) {
|
||||
const options = useMemo(
|
||||
() =>
|
||||
typeof inputOptions[0] === 'string' ? inputOptions.map((opt) => ({ value: opt, label: opt })) : inputOptions,
|
||||
[inputOptions]
|
||||
);
|
||||
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const [selected, setSelected] = useState(
|
||||
Math.max(
|
||||
options.findIndex(({ value }) => value === propSelected),
|
||||
0
|
||||
)
|
||||
);
|
||||
const [focused, setFocused] = useState(null);
|
||||
|
||||
const ref = useRef(null);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(value, label) => {
|
||||
setSelected(options.findIndex((opt) => opt.value === value));
|
||||
onChange && onChange(value, label);
|
||||
setShowMenu(false);
|
||||
},
|
||||
[onChange, options]
|
||||
);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
setShowMenu(true);
|
||||
}, [setShowMenu]);
|
||||
|
||||
const handleKeydown = useCallback(
|
||||
(event) => {
|
||||
switch (event.key) {
|
||||
case 'Enter': {
|
||||
if (!showMenu) {
|
||||
setShowMenu(true);
|
||||
setFocused(selected);
|
||||
} else {
|
||||
setSelected(focused);
|
||||
onChange && onChange(options[focused].value, options[focused].label);
|
||||
setShowMenu(false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ArrowDown': {
|
||||
const newIndex = focused + 1;
|
||||
newIndex < options.length && setFocused(newIndex);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ArrowUp': {
|
||||
const newIndex = focused - 1;
|
||||
newIndex > -1 && setFocused(newIndex);
|
||||
break;
|
||||
}
|
||||
|
||||
// no default
|
||||
}
|
||||
},
|
||||
[onChange, options, showMenu, setShowMenu, setFocused, focused, selected]
|
||||
);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
setShowMenu(false);
|
||||
}, [setShowMenu]);
|
||||
const [selected, setSelected] = useState();
|
||||
const [datePickerValue, setDatePickerValue] = useState();
|
||||
|
||||
// Reset the state if the prop value changes
|
||||
useEffect(() => {
|
||||
@@ -85,25 +38,219 @@ export default function Select({ label, onChange, options: inputOptions = [], se
|
||||
// DO NOT include `selected`
|
||||
}, [options, propSelected]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<TextField
|
||||
inputRef={ref}
|
||||
label={label}
|
||||
onchange={onChange}
|
||||
onclick={handleClick}
|
||||
onkeydown={handleKeydown}
|
||||
readonly
|
||||
trailingIcon={showMenu ? ArrowDropup : ArrowDropdown}
|
||||
value={options[selected]?.label}
|
||||
/>
|
||||
{showMenu ? (
|
||||
<Menu className="rounded-t-none" onDismiss={handleDismiss} relativeTo={ref} widthRelative>
|
||||
{options.map(({ value, label }, i) => (
|
||||
<MenuItem key={value} label={label} focus={focused === i} onSelect={handleSelect} value={value} />
|
||||
))}
|
||||
</Menu>
|
||||
) : null}
|
||||
</Fragment>
|
||||
useEffect(() => {
|
||||
if (type === 'datepicker') {
|
||||
if ('after' && 'before' in propSelected) {
|
||||
if (!propSelected.before || !propSelected.after) return setDatePickerValue('all');
|
||||
|
||||
for (let i = 0; i < inputOptions.length; i++) {
|
||||
if (
|
||||
inputOptions[i].value &&
|
||||
Object.entries(inputOptions[i].value).sort().toString() === Object.entries(propSelected).sort().toString()
|
||||
) {
|
||||
setDatePickerValue(inputOptions[i]?.label);
|
||||
break;
|
||||
} else {
|
||||
setDatePickerValue(
|
||||
`${new Date(propSelected.after * 1000).toLocaleDateString()} -> ${new Date(
|
||||
propSelected.before * 1000 - 1
|
||||
).toLocaleDateString()}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (type === 'dropdown') {
|
||||
setSelected(
|
||||
Math.max(
|
||||
options.findIndex(({ value }) => Object.values(propSelected).includes(value)),
|
||||
0
|
||||
)
|
||||
);
|
||||
}
|
||||
}, [type, options, inputOptions, propSelected, setSelected]);
|
||||
|
||||
const [focused, setFocused] = useState(null);
|
||||
const [showCalender, setShowCalender] = useState(false);
|
||||
const calenderRef = useRef(null);
|
||||
const ref = useRef(null);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(value) => {
|
||||
setSelected(options.findIndex(({ value }) => Object.values(propSelected).includes(value)));
|
||||
setShowMenu(false);
|
||||
|
||||
//show calender date range picker
|
||||
if (value === 'custom_range') return setShowCalender(true);
|
||||
onChange && onChange(value);
|
||||
},
|
||||
[onChange, options, propSelected, setSelected]
|
||||
);
|
||||
|
||||
const handleDateRange = useCallback(
|
||||
(range) => {
|
||||
onChange && onChange(range);
|
||||
setShowMenu(false);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
setShowMenu(true);
|
||||
}, [setShowMenu]);
|
||||
|
||||
const handleKeydownDatePicker = useCallback(
|
||||
(event) => {
|
||||
switch (event.key) {
|
||||
case 'Enter': {
|
||||
if (!showMenu) {
|
||||
setShowMenu(true);
|
||||
setFocused(selected);
|
||||
} else {
|
||||
setSelected(focused);
|
||||
if (options[focused].value === 'custom_range') {
|
||||
setShowMenu(false);
|
||||
return setShowCalender(true);
|
||||
}
|
||||
|
||||
onChange && onChange(options[focused].value);
|
||||
setShowMenu(false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ArrowDown': {
|
||||
event.preventDefault();
|
||||
const newIndex = focused + 1;
|
||||
newIndex < options.length && setFocused(newIndex);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ArrowUp': {
|
||||
event.preventDefault();
|
||||
const newIndex = focused - 1;
|
||||
newIndex > -1 && setFocused(newIndex);
|
||||
break;
|
||||
}
|
||||
|
||||
// no default
|
||||
}
|
||||
},
|
||||
[onChange, options, showMenu, setShowMenu, setFocused, focused, selected]
|
||||
);
|
||||
|
||||
const handleKeydown = useCallback(
|
||||
(event) => {
|
||||
switch (event.key) {
|
||||
case 'Enter': {
|
||||
if (!showMenu) {
|
||||
setShowMenu(true);
|
||||
setFocused(selected);
|
||||
} else {
|
||||
setSelected(focused);
|
||||
onChange && onChange({ [paramName]: options[focused].value });
|
||||
setShowMenu(false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ArrowDown': {
|
||||
event.preventDefault();
|
||||
const newIndex = focused + 1;
|
||||
newIndex < options.length && setFocused(newIndex);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ArrowUp': {
|
||||
event.preventDefault();
|
||||
const newIndex = focused - 1;
|
||||
newIndex > -1 && setFocused(newIndex);
|
||||
break;
|
||||
}
|
||||
|
||||
// no default
|
||||
}
|
||||
},
|
||||
[onChange, options, showMenu, setShowMenu, setFocused, focused, selected, paramName]
|
||||
);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
setShowMenu(false);
|
||||
}, [setShowMenu]);
|
||||
|
||||
const findDOMNodes = (component) => {
|
||||
return (component && (component.base || (component.nodeType === 1 && component))) || null;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const addBackDrop = (e) => {
|
||||
if (showCalender && !findDOMNodes(calenderRef.current).contains(e.target)) {
|
||||
setShowCalender(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('click', addBackDrop);
|
||||
|
||||
return function cleanup() {
|
||||
window.removeEventListener('click', addBackDrop);
|
||||
};
|
||||
}, [showCalender]);
|
||||
|
||||
switch (type) {
|
||||
case 'datepicker':
|
||||
return (
|
||||
<Fragment>
|
||||
<DatePicker
|
||||
inputRef={ref}
|
||||
label={label}
|
||||
onchange={onChange}
|
||||
onclick={handleClick}
|
||||
onkeydown={handleKeydownDatePicker}
|
||||
trailingIcon={showMenu ? ArrowDropup : ArrowDropdown}
|
||||
value={datePickerValue}
|
||||
/>
|
||||
{showCalender && (
|
||||
<Menu className="rounded-t-none" onDismiss={handleDismiss} relativeTo={ref}>
|
||||
<Calender onChange={handleDateRange} calenderRef={calenderRef} close={() => setShowCalender(false)} />
|
||||
</Menu>
|
||||
)}
|
||||
{showMenu ? (
|
||||
<Menu className="rounded-t-none" onDismiss={handleDismiss} relativeTo={ref} widthRelative>
|
||||
{options.map(({ value, label }, i) => (
|
||||
<MenuItem key={value} label={label} focus={focused === i} onSelect={handleSelect} value={value} />
|
||||
))}
|
||||
</Menu>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
// case 'dropdown':
|
||||
default:
|
||||
return (
|
||||
<Fragment>
|
||||
<TextField
|
||||
inputRef={ref}
|
||||
label={label}
|
||||
onchange={onChange}
|
||||
onclick={handleClick}
|
||||
onkeydown={handleKeydown}
|
||||
readonly
|
||||
trailingIcon={showMenu ? ArrowDropup : ArrowDropdown}
|
||||
value={options[selected]?.label}
|
||||
/>
|
||||
{showMenu ? (
|
||||
<Menu className="rounded-t-none" onDismiss={handleDismiss} relativeTo={ref} widthRelative>
|
||||
{options.map(({ value, label }, i) => (
|
||||
<MenuItem
|
||||
key={value}
|
||||
label={label}
|
||||
focus={focused === i}
|
||||
onSelect={handleSelect}
|
||||
value={{ [paramName]: value }}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -5,21 +5,40 @@ import { fireEvent, render, screen } from '@testing-library/preact';
|
||||
describe('Select', () => {
|
||||
test('on focus, shows a menu', async () => {
|
||||
const handleChange = jest.fn();
|
||||
render(<Select label="Tacos" onChange={handleChange} options={['tacos', 'burritos']} />);
|
||||
render(
|
||||
<Select
|
||||
label="Tacos"
|
||||
type="dropdown"
|
||||
onChange={handleChange}
|
||||
options={['all', 'tacos', 'burritos']}
|
||||
paramName={['dinner']}
|
||||
selected=""
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('textbox'));
|
||||
expect(screen.queryByRole('listbox')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('option', { name: 'all' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('option', { name: 'tacos' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('option', { name: 'burritos' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.queryByRole('option', { name: 'burritos' }));
|
||||
expect(handleChange).toHaveBeenCalledWith('burritos', 'burritos');
|
||||
fireEvent.click(screen.queryByRole('option', { name: 'tacos' }));
|
||||
expect(handleChange).toHaveBeenCalledWith({ dinner: 'tacos' });
|
||||
});
|
||||
|
||||
test('allows keyboard navigation', async () => {
|
||||
const handleChange = jest.fn();
|
||||
render(<Select label="Tacos" onChange={handleChange} options={['tacos', 'burritos']} />);
|
||||
render(
|
||||
<Select
|
||||
label="Tacos"
|
||||
type="dropdown"
|
||||
onChange={handleChange}
|
||||
options={['tacos', 'burritos']}
|
||||
paramName={['dinner']}
|
||||
selected=""
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
const input = screen.getByRole('textbox');
|
||||
@@ -29,6 +48,6 @@ describe('Select', () => {
|
||||
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown', code: 'ArrowDown' });
|
||||
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
||||
expect(handleChange).toHaveBeenCalledWith('burritos', 'burritos');
|
||||
expect(handleChange).toHaveBeenCalledWith({ dinner: 'burritos' });
|
||||
});
|
||||
});
|
||||
|
@@ -18,7 +18,8 @@ export const useSearchString = (limit, searchParams) => {
|
||||
const removeDefaultSearchKeys = useCallback((searchParams) => {
|
||||
searchParams.delete('limit');
|
||||
searchParams.delete('include_thumbnails');
|
||||
searchParams.delete('before');
|
||||
// removed deletion of "before" as its used by DatePicker
|
||||
// searchParams.delete('before');
|
||||
}, []);
|
||||
|
||||
return { searchString, setSearchString, removeDefaultSearchKeys };
|
||||
|
18
web/src/icons/ArrowLeft.jsx
Normal file
18
web/src/icons/ArrowLeft.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { h } from 'preact';
|
||||
import { memo } from 'preact/compat';
|
||||
|
||||
export function ArrowLeft({ className = '' }) {
|
||||
return (
|
||||
<svg
|
||||
className={`fill-current ${className}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 0c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm-1.218 19l-1.782-1.75 5.25-5.25-5.25-5.25 1.782-1.75 6.968 7-6.968 7z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ArrowLeft);
|
12
web/src/icons/ArrowRight.jsx
Normal file
12
web/src/icons/ArrowRight.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { h } from 'preact';
|
||||
import { memo } from 'preact/compat';
|
||||
|
||||
export function ArrowRight({ className = '' }) {
|
||||
return (
|
||||
<svg className={`fill-current ${className}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M5 3l3.057-3 11.943 12-11.943 12-3.057-3 9-9z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ArrowRight);
|
12
web/src/icons/ArrowRightDouble.jsx
Normal file
12
web/src/icons/ArrowRightDouble.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { h } from 'preact';
|
||||
import { memo } from 'preact/compat';
|
||||
|
||||
export function ArrowRightDouble({ className = '' }) {
|
||||
return (
|
||||
<svg className={`fill-current ${className}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M0 3.795l2.995-2.98 11.132 11.185-11.132 11.186-2.995-2.981 8.167-8.205-8.167-8.205zm18.04 8.205l-8.167 8.205 2.995 2.98 11.132-11.185-11.132-11.186-2.995 2.98 8.167 8.206z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ArrowRightDouble);
|
@@ -29,12 +29,8 @@ function Camera({ name, conf }) {
|
||||
const { payload: snapshotValue, send: sendSnapshots } = useSnapshotsState(name);
|
||||
const href = `/cameras/${name}`;
|
||||
const buttons = useMemo(() => {
|
||||
const result = [{ name: 'Events', href: `/events?camera=${name}` }];
|
||||
if (conf.record.enabled) {
|
||||
result.push({ name: 'Recordings', href: `/recording/${name}` });
|
||||
}
|
||||
return result;
|
||||
}, [name, conf.record.enabled]);
|
||||
return [{ name: 'Events', href: `/events?camera=${name}` }, { name: 'Recordings', href: `/recording/${name}` }];
|
||||
}, [name]);
|
||||
const icons = useMemo(
|
||||
() => [
|
||||
{
|
||||
|
@@ -99,7 +99,7 @@ export default function Event({ eventId, close, scrollRef }) {
|
||||
}
|
||||
|
||||
const startime = new Date(data.start_time * 1000);
|
||||
const endtime = new Date(data.end_time * 1000);
|
||||
const endtime = data.end_time ? new Date(data.end_time * 1000) : null;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex md:flex-row justify-between flex-wrap flex-col">
|
||||
@@ -155,7 +155,7 @@ export default function Event({ eventId, close, scrollRef }) {
|
||||
<Tr index={1}>
|
||||
<Td>Timeframe</Td>
|
||||
<Td>
|
||||
{startime.toLocaleString()} – {endtime.toLocaleString()}
|
||||
{startime.toLocaleString()}{endtime === null ? ` – ${endtime.toLocaleString()}`:''}
|
||||
</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
@@ -186,7 +186,7 @@ export default function Event({ eventId, close, scrollRef }) {
|
||||
},
|
||||
],
|
||||
poster: data.has_snapshot
|
||||
? `${apiHost}/clips/${data.camera}-${eventId}.jpg`
|
||||
? `${apiHost}/api/events/${eventId}/snapshot.jpg`
|
||||
: `data:image/jpeg;base64,${data.thumbnail}`,
|
||||
}}
|
||||
seekOptions={{ forward: 10, back: 5 }}
|
||||
@@ -199,7 +199,7 @@ export default function Event({ eventId, close, scrollRef }) {
|
||||
<img
|
||||
src={
|
||||
data.has_snapshot
|
||||
? `${apiHost}/clips/${data.camera}-${eventId}.jpg`
|
||||
? `${apiHost}/api/events/${eventId}/snapshot.jpg`
|
||||
: `data:image/jpeg;base64,${data.thumbnail}`
|
||||
}
|
||||
alt={`${data.label} at ${(data.top_score * 100).toFixed(1)}% confidence`}
|
||||
|
@@ -1,31 +1,26 @@
|
||||
import { h } from 'preact';
|
||||
import Select from '../../../components/Select';
|
||||
import { useCallback, useMemo } from 'preact/hooks';
|
||||
import { useCallback } from 'preact/hooks';
|
||||
|
||||
const Filter = ({ onChange, searchParams, paramName, options }) => {
|
||||
function Filter({ onChange, searchParams, paramName, options, ...rest }) {
|
||||
const handleSelect = useCallback(
|
||||
(key) => {
|
||||
const newParams = new URLSearchParams(searchParams.toString());
|
||||
if (key !== 'all') {
|
||||
newParams.set(paramName, key);
|
||||
} else {
|
||||
newParams.delete(paramName);
|
||||
}
|
||||
Object.keys(key).map((entries) => {
|
||||
if (key[entries] !== 'all') {
|
||||
newParams.set(entries, key[entries]);
|
||||
} else {
|
||||
paramName.map((p) => newParams.delete(p));
|
||||
}
|
||||
});
|
||||
|
||||
onChange(newParams);
|
||||
},
|
||||
[searchParams, paramName, onChange]
|
||||
);
|
||||
|
||||
const selectOptions = useMemo(() => ['all', ...options], [options]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
label={`${paramName.charAt(0).toUpperCase()}${paramName.substr(1)}`}
|
||||
onChange={handleSelect}
|
||||
options={selectOptions}
|
||||
selected={searchParams.get(paramName) || 'all'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const obj = {};
|
||||
paramName.map((name) => Object.assign(obj, { [name]: searchParams.get(name) }), [searchParams]);
|
||||
return <Select onChange={handleSelect} options={options} selected={obj} paramName={paramName} {...rest} />;
|
||||
}
|
||||
export default Filter;
|
||||
|
@@ -3,7 +3,13 @@ import { useCallback, useMemo } from 'preact/hooks';
|
||||
import Link from '../../../components/Link';
|
||||
import { route } from 'preact-router';
|
||||
|
||||
const Filterable = ({ onFilter, pathname, searchParams, paramName, name, removeDefaultSearchKeys }) => {
|
||||
function Filterable({ onFilter, pathname, searchParams, paramName, name }) {
|
||||
const removeDefaultSearchKeys = useCallback((searchParams) => {
|
||||
searchParams.delete('limit');
|
||||
searchParams.delete('include_thumbnails');
|
||||
// searchParams.delete('before');
|
||||
}, []);
|
||||
|
||||
const href = useMemo(() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set(paramName, name);
|
||||
@@ -27,6 +33,6 @@ const Filterable = ({ onFilter, pathname, searchParams, paramName, name, removeD
|
||||
{name}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default Filterable;
|
||||
|
@@ -1,11 +1,13 @@
|
||||
import { h } from 'preact';
|
||||
import Filter from './filter';
|
||||
import { useConfig } from '../../../api';
|
||||
import { useMemo } from 'preact/hooks';
|
||||
import { useMemo, useState } from 'preact/hooks';
|
||||
import { DateFilterOptions } from '../../../components/DatePicker';
|
||||
import Button from '../../../components/Button';
|
||||
|
||||
const Filters = ({ onChange, searchParams }) => {
|
||||
const [viewFilters, setViewFilters] = useState(false);
|
||||
const { data } = useConfig();
|
||||
|
||||
const cameras = useMemo(() => Object.keys(data.cameras), [data]);
|
||||
|
||||
const zones = useMemo(
|
||||
@@ -27,12 +29,52 @@ const Filters = ({ onChange, searchParams }) => {
|
||||
}, data.objects?.track || [])
|
||||
.filter((value, i, self) => self.indexOf(value) === i);
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<div className="flex space-x-4">
|
||||
<Filter onChange={onChange} options={cameras} paramName="camera" searchParams={searchParams} />
|
||||
<Filter onChange={onChange} options={zones} paramName="zone" searchParams={searchParams} />
|
||||
<Filter onChange={onChange} options={labels} paramName="label" searchParams={searchParams} />
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => setViewFilters(!viewFilters)}
|
||||
className="block xs:hidden w-full mb-4 text-center"
|
||||
type="text"
|
||||
>
|
||||
{`${viewFilters ? 'Hide Filter' : 'Filter'}`}
|
||||
</Button>
|
||||
<div className={`xs:flex space-y-1 xs:space-y-0 xs:space-x-4 ${viewFilters ? 'flex-col' : 'hidden'}`}>
|
||||
<Filter
|
||||
type="dropdown"
|
||||
onChange={onChange}
|
||||
options={['all', ...cameras]}
|
||||
paramName={['camera']}
|
||||
label="Camera"
|
||||
searchParams={searchParams}
|
||||
/>
|
||||
|
||||
<Filter
|
||||
type="dropdown"
|
||||
onChange={onChange}
|
||||
options={['all', ...zones]}
|
||||
paramName={['zone']}
|
||||
label="Zone"
|
||||
searchParams={searchParams}
|
||||
/>
|
||||
|
||||
<Filter
|
||||
type="dropdown"
|
||||
onChange={onChange}
|
||||
options={['all', ...labels]}
|
||||
paramName={['label']}
|
||||
label="Label"
|
||||
searchParams={searchParams}
|
||||
/>
|
||||
|
||||
<Filter
|
||||
type="datepicker"
|
||||
onChange={onChange}
|
||||
options={DateFilterOptions}
|
||||
paramName={['before', 'after']}
|
||||
label="DatePicker"
|
||||
searchParams={searchParams}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@@ -42,7 +42,7 @@ const EventsRow = memo(
|
||||
);
|
||||
|
||||
const start = new Date(parseInt(startTime * 1000, 10));
|
||||
const end = new Date(parseInt(endTime * 1000, 10));
|
||||
const end = endTime ? new Date(parseInt(endTime * 1000, 10)) : null;
|
||||
|
||||
return (
|
||||
<Tbody reference={innerRef}>
|
||||
@@ -102,7 +102,7 @@ const EventsRow = memo(
|
||||
</Td>
|
||||
<Td>{start.toLocaleDateString()}</Td>
|
||||
<Td>{start.toLocaleTimeString()}</Td>
|
||||
<Td>{end.toLocaleTimeString()}</Td>
|
||||
<Td>{end === null ? 'In progress' : end.toLocaleTimeString()}</Td>
|
||||
</Tr>
|
||||
{viewEvent === id ? (
|
||||
<Tr className="border-b-1">
|
||||
|
@@ -66,6 +66,9 @@ export default function Recording({ camera, date, hour, seconds }) {
|
||||
this.player.currentTime(seconds);
|
||||
}
|
||||
}
|
||||
// Force playback rate to be correct
|
||||
const playbackRate = this.player.playbackRate();
|
||||
this.player.defaultPlaybackRate(playbackRate);
|
||||
}
|
||||
|
||||
return (
|
||||
|
@@ -46,7 +46,7 @@ describe('Cameras Route', () => {
|
||||
|
||||
expect(screen.queryByLabelText('Loading…')).not.toBeInTheDocument();
|
||||
|
||||
expect(screen.queryAllByText('Recordings')).toHaveLength(1);
|
||||
expect(screen.queryAllByText('Recordings')).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('buttons toggle detect, clips, and snapshots', async () => {
|
||||
|
Reference in New Issue
Block a user