Compare commits

...

73 Commits

Author SHA1 Message Date
Blake Blackshear
b1e84ca7fe allow dash in camera name 2022-02-05 14:31:06 -06:00
Blake Blackshear
e6ec5cb097 make motion the default retain mode 2022-02-05 09:38:22 -06:00
Blake Blackshear
23c70acd51 update stationary interval docs 2022-02-05 09:38:22 -06:00
Blake Blackshear
091648187f make expire interval configurable for users wanting to minimize i/o 2022-02-05 09:38:22 -06:00
Blake Blackshear
2b7d38f947 avoid extra tracking work on stationary frames 2022-02-05 09:38:22 -06:00
Blake Blackshear
f801930588 use iou instead of centroid 2022-02-05 09:38:22 -06:00
Blake Blackshear
955c2779d9 dont stop scanning when there are other regions 2022-02-05 09:38:22 -06:00
Blake Blackshear
037f8667a6 default periodic checks to never 2022-02-05 09:38:22 -06:00
Blake Blackshear
307068a61f scan the frame on startup 2022-02-05 09:38:22 -06:00
Blake Blackshear
077d900b44 require a position change to be an active object 2022-02-05 09:38:22 -06:00
Blake Blackshear
92f9195075 randomize the region multiplier for variation 2022-02-05 09:38:22 -06:00
Blake Blackshear
82c60093d1 improve method for determining position
compares the centroid to a history of bounding boxes
2022-02-05 09:38:22 -06:00
Blake Blackshear
944b9181e0 if recording not on disk, delete from db and return 2022-02-05 09:38:22 -06:00
Blake Blackshear
326b368e82 cleanup clean snapshots on event deletion too 2022-02-05 09:38:22 -06:00
Blake Blackshear
040d8c9778 require url safe camera names 2022-02-05 09:38:22 -06:00
Bernt Christian Egeland
273f803c7c Event Datepicker (#2428)
* new datepicker

* dev

* dev

* dev

* fix for version 0.10

* added rounded corners for date range

* lint

* Commented out some Select.test.

* improved date range selection

* improved functions with useCallback

* improved Select.test.jsx

* keyboard navigation

* keyboard navigation

* added dropdown menu icon

* Hide filters on xs, Button to show

* check if to far left before right

* Filter button text

* improved local timezone
2022-02-02 07:26:45 -06:00
Yuriy Sannikov
bd8e23833c Run python unit tests in a github actions (#2589)
* tox tests initial commit

* run tests in the Dockerfile during the build phase

* remove local tests

Co-authored-by: YS <ys@gm.com>
2022-01-14 07:31:25 -06:00
Yuriy Sannikov
9edf38347c safe refactoring (#2552)
Co-authored-by: YS <ys@gm.com>
2021-12-31 11:59:43 -06:00
TJ Horner
1569ce7cf6 Change JPEG mime type (#2543) 2021-12-30 17:03:29 -06:00
Blake Blackshear
db1255aa7f disable disk sync on startup 2021-12-13 06:51:03 -06:00
Blake Blackshear
609b436ed8 fix migrations 2021-12-13 06:50:06 -06:00
Blake Blackshear
95bdf9fe34 check for apex dir 2021-12-12 10:27:01 -06:00
Ryan McLean
251d29aa38 #2117 change entered_zones from set to list so that they are not automatically alphabetically ordered (#2212) 2021-12-12 09:29:57 -06:00
Justin Goette
156e1a4dc2 Allow for ".yaml" (#2244)
* allow for ".yaml"

* remove unused import
2021-12-12 09:27:05 -06:00
Matt Clayton
a5c13e7455 Add temperature of coral tpu to telemetry mqtt message 2021-12-12 09:22:32 -06:00
Blake Blackshear
fcb4aaef0d limit vod response cache 2021-12-12 09:21:45 -06:00
Blake Blackshear
589432bc89 update docs 2021-12-12 09:21:45 -06:00
Blake Blackshear
b19a02888a expire overlapping segments based on mode 2021-12-12 09:21:45 -06:00
Blake Blackshear
18fd50dfce store objects and motion counts in the db 2021-12-12 09:21:45 -06:00
Blake Blackshear
df0246aed8 warn when retention mismatch 2021-12-12 09:21:45 -06:00
Blake Blackshear
cbb2882123 refactor segment stats logic 2021-12-12 09:21:45 -06:00
Blake Blackshear
9f18629df3 switch to retain config instead of retain_days 2021-12-12 09:21:45 -06:00
Blake Blackshear
63f8034e46 pass processed tracked objects 2021-12-12 09:21:45 -06:00
Blake Blackshear
f3efc0667f retain frame data for recording maintenance 2021-12-12 09:21:45 -06:00
Blake Blackshear
af001321a8 fix process_clip 2021-12-12 09:21:45 -06:00
Blake Blackshear
92e08b92f5 sync recordings with disk once on startup 2021-12-12 09:21:45 -06:00
Blake Blackshear
26241b0877 no need to expire recordings every minute 2021-12-12 09:21:45 -06:00
Blake Blackshear
c1155af169 ensure cache copies when events have ended 2021-11-21 09:43:37 -06:00
Blake Blackshear
77c1f1bb1b cleanup missing files from database once per hour 2021-11-21 07:55:35 -06:00
Blake Blackshear
ae3c01fe2d handle missing file edge case 2021-11-21 07:26:31 -06:00
Blake Blackshear
7a2a85d253 log error messages on vod endpoints 2021-11-21 07:25:36 -06:00
Blake Blackshear
77c66d4e49 ensure duration > 0 for segments 2021-11-21 07:25:01 -06:00
Blake Blackshear
494e5ac4ec use snapshot url to support in progress events 2021-11-20 09:52:02 -06:00
Blake Blackshear
63b7465452 ensure stationary interval is greater than 0 2021-11-20 09:15:03 -06:00
Blake Blackshear
e6d2df5661 add duration to cache 2021-11-19 16:56:00 -06:00
Blake Blackshear
a3301e0347 avoid running ffprobe for each segment multiple times 2021-11-19 07:28:51 -06:00
Blake Blackshear
3d556cc2cb warn if no wait time 2021-11-19 07:19:14 -06:00
Blake Blackshear
585efe1a0f keep 5 segments in cache 2021-11-19 07:16:29 -06:00
Blake Blackshear
c7d47439dd better cache handling 2021-11-17 08:57:57 -06:00
Blake Blackshear
19a6978228 avoid proactive messages with retain_days 0 and handle first pass 2021-11-17 07:44:58 -06:00
Blake Blackshear
1ebb8a54bf avoid divide by zero 2021-11-17 07:29:23 -06:00
Blake Blackshear
ae968044d6 revert switch to b/w frame prep 2021-11-17 07:28:53 -06:00
Blake Blackshear
b912851e49 fix default motion comment 2021-11-15 06:54:03 -06:00
Blake Blackshear
14c74e4361 more robust cache management 2021-11-10 21:12:41 -06:00
Blake Blackshear
51fb532e1a set retain when setting switches from frontend 2021-11-09 07:40:23 -06:00
Blake Blackshear
3541f966e3 error handling for the recording maintainer 2021-11-09 07:05:21 -06:00
Blake Blackshear
c7faef8faa don't modify ffmpeg_cmd object 2021-11-08 19:05:39 -06:00
Blake Blackshear
cdd3000315 fix ffmpeg config for env vars 2021-11-08 18:20:47 -06:00
Blake Blackshear
1c1c28d0e5 create ffmpeg commands on startup 2021-11-08 07:36:21 -06:00
Blake Blackshear
4422e86907 clarify shm in docs 2021-11-08 07:36:21 -06:00
Blake Blackshear
8f43a2d109 use resolution of clip 2021-11-08 07:36:21 -06:00
Blake Blackshear
bd7755fdd3 revamp process clip 2021-11-08 07:36:21 -06:00
Blake Blackshear
d554175631 no longer make motion settings dynamic 2021-11-08 07:36:21 -06:00
Blake Blackshear
ff667b019a remove min frame height of 180 and increase contour area 2021-11-08 07:36:21 -06:00
Blake Blackshear
57dcb29f8b consolidate regions 2021-11-08 07:36:21 -06:00
Blake Blackshear
9dc6c423b7 improve contrast 2021-11-08 07:36:21 -06:00
Blake Blackshear
58117e2a3e check for overlapping motion boxes 2021-11-08 07:36:21 -06:00
Blake Blackshear
5bec438f9c config option for stationary detection interval 2021-11-01 07:58:30 -05:00
Blake Blackshear
24cc63d6d3 drop high overlap detections 2021-11-01 07:58:30 -05:00
Blake Blackshear
d17bd74c9a reduce detection rate for stationary objects 2021-11-01 07:58:30 -05:00
Blake Blackshear
8f101ccca8 improve box merging and keep tracking 2021-11-01 07:58:30 -05:00
Blake Blackshear
b63c56d810 only save recordings when an event is in progress 2021-10-25 06:40:36 -05:00
Blake Blackshear
61c62d4685 version tick 2021-10-25 06:40:02 -05:00
44 changed files with 2013 additions and 392 deletions

View File

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

@@ -8,6 +8,7 @@ models
*.mp4
*.ts
*.db
*.csv
frigate/version.py
web/build
web/node_modules

View File

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

View File

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

View File

@@ -159,6 +159,9 @@ 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: 0)
# 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: Object configuration
# NOTE: Can be overridden at the camera level
@@ -192,10 +195,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 +212,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 +225,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 +259,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

View File

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

View File

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

View File

@@ -67,6 +67,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 +111,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 +212,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 +280,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):

View File

@@ -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,11 @@ 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,
)
class FilterConfig(FrigateBaseModel):
@@ -461,7 +476,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 +510,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 +537,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 +549,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 +644,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):
@@ -806,6 +827,26 @@ 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
if (
camera_config.record.retain.days != 0
and camera_config.record.retain.mode != RetainModeEnum.all
and camera_config.record.events.retain.mode
!= camera_config.record.retain.mode
):
logger.warning(
f"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 +864,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)

View File

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

View File

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

View File

@@ -30,6 +30,11 @@ class EventProcessor(threading.Thread):
self.stop_event = stop_event
def run(self):
# set an end_time on events without an end_time on startup
Event.update(end_time=Event.start_time + 30).where(
Event.end_time == None
).execute()
while not self.stop_event.is_set():
try:
event_type, camera, event_data = self.event_queue.get(timeout=10)
@@ -38,14 +43,35 @@ class EventProcessor(threading.Thread):
logger.debug(f"Event received: {event_type} {camera} {event_data['id']}")
event_config: EventsConfig = self.config.cameras[camera].record.events
if event_type == "start":
self.events_in_process[event_data["id"]] = event_data
if event_type == "end":
event_config: EventsConfig = self.config.cameras[camera].record.events
elif event_type == "update":
self.events_in_process[event_data["id"]] = event_data
# TODO: this will generate a lot of db activity possibly
if event_data["has_clip"] or event_data["has_snapshot"]:
Event.create(
Event.replace(
id=event_data["id"],
label=event_data["label"],
camera=camera,
start_time=event_data["start_time"] - event_config.pre_capture,
end_time=None,
top_score=event_data["top_score"],
false_positive=event_data["false_positive"],
zones=list(event_data["entered_zones"]),
thumbnail=event_data["thumbnail"],
region=event_data["region"],
box=event_data["box"],
area=event_data["area"],
has_clip=event_data["has_clip"],
has_snapshot=event_data["has_snapshot"],
).execute()
elif event_type == "end":
if event_data["has_clip"] or event_data["has_snapshot"]:
Event.replace(
id=event_data["id"],
label=event_data["label"],
camera=camera,
@@ -60,11 +86,15 @@ class EventProcessor(threading.Thread):
area=event_data["area"],
has_clip=event_data["has_clip"],
has_snapshot=event_data["has_snapshot"],
)
).execute()
del self.events_in_process[event_data["id"]]
self.event_processed_queue.put((event_data["id"], camera))
# set an end_time on events without an end_time before exiting
Event.update(end_time=datetime.datetime.now().timestamp()).where(
Event.end_time == None
).execute()
logger.info(f"Exiting event processor...")

View File

@@ -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"
@@ -321,7 +324,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 +361,10 @@ 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 +379,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 +441,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
@@ -657,10 +661,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 +698,20 @@ 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
)
return vod_ts(event.camera, event.start_time, end_ts)
duration = int((event.end_time - event.start_time) * 1000)
return jsonify(

View File

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

View File

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

View File

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

View File

@@ -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
@@ -147,7 +147,8 @@ 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):
@@ -176,8 +177,10 @@ class TrackedObject:
"box": self.obj_data["box"],
"area": self.obj_data["area"],
"region": self.obj_data["region"],
"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 +265,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]]
@@ -584,6 +594,7 @@ class TrackedObjectProcessor(threading.Thread):
event_queue,
event_processed_queue,
video_output_queue,
recordings_info_queue,
stop_event,
):
threading.Thread.__init__(self)
@@ -595,6 +606,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 +615,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 +627,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 +739,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 +763,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 +788,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 +837,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,
)

View File

@@ -13,31 +13,100 @@ 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 10 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:
self.tracked_objects[id]["motionless_count"] = 0
self.tracked_objects[id]["position_changes"] += 1
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
def match_and_update(self, frame_time, new_objects):
# group by name
new_object_groups = defaultdict(lambda: [])

View File

@@ -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,28 @@ 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.first_pass = True
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 +79,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 +91,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 +284,61 @@ class RecordingMaintainer(threading.Thread):
start_time=start_time.timestamp(),
end_time=end_time.timestamp(),
duration=duration,
motion=motion_count,
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)
if wait_time == 0 and not self.first_pass:
logger.warning(
"Cache is taking longer than 5 seconds to clear. Your recordings disk may be too slow."
)
if self.first_pass:
self.first_pass = False
logger.info(f"Exiting recording maintenance...")
@@ -160,7 +363,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 +389,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 +420,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 +435,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 +448,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 +477,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 +495,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 +510,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)

View File

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

View File

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

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

View File

@@ -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)
@@ -601,6 +601,24 @@ 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

View File

@@ -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,
)
@@ -364,6 +368,7 @@ def track_camera(
frame_queue,
frame_shape,
model_shape,
config.detect,
frame_manager,
motion_detector,
object_detector,
@@ -379,26 +384,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 +449,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 +470,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...")
@@ -487,33 +505,86 @@ def process_frames(
# look for motion
motion_boxes = motion_detector.detect(frame)
# only get the tracked object boxes that intersect with motion
# get stationary object ids
# check every Nth frame for stationary objects
# disappeared objects are not stationary
# also check for overlapping motion boxes
stationary_object_ids = [
obj["id"]
for obj in object_tracker.tracked_objects.values()
# if there hasn't been motion for 10 frames
if obj["motionless_count"] >= 10
# and it isn't due for a periodic check
and (
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)
]
# get tracked object boxes that aren't stationary
tracked_object_boxes = [
obj["box"]
for obj in object_tracker.tracked_objects.values()
if intersects_any(obj["box"], motion_boxes)
if not obj["id"] in stationary_object_ids
]
# combine motion boxes with known locations of existing objects
combined_boxes = reduce_boxes(motion_boxes + tracked_object_boxes)
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], 1.2)
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
]
# combine overlapping regions
combined_regions = reduce_boxes(regions)
# re-compute regions
# consolidate regions with heavy overlap
regions = [
calculate_region(frame_shape, a[0], a[1], a[2], a[3], 1.0)
for a in combined_regions
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)
]
# 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
# resize regions and detect
detections = []
# seed with stationary objects
detections = [
(
obj["label"],
obj["score"],
obj["box"],
obj["area"],
obj["region"],
)
for obj in object_tracker.tracked_objects.values()
if obj["id"] in stationary_object_ids
]
for region in regions:
detections.extend(
detect(
@@ -529,7 +600,7 @@ def process_frames(
#########
# merge objects, check for clipped objects and look again up to 4 times
#########
refining = True
refining = len(regions) > 0
refine_count = 0
while refining and refine_count < 4:
refining = False
@@ -556,7 +627,7 @@ def process_frames(
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]
frame_shape, box[0], box[1], box[2], box[3], region_min_size
)
regions.append(region)
@@ -582,14 +653,51 @@ def process_frames(
if refining:
refine_count += 1
# Limit to the detections overlapping with motion areas
# to avoid picking up stationary background objects
detections_with_motion = [
d for d in detections if intersects_any(d[2], motion_boxes)
]
## drop detections that overlap too much
consolidated_detections = []
# now that we have refined our detections, we need to track objects
object_tracker.match_and_update(frame_time, detections_with_motion)
# 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():

View File

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

View 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

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

View File

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

View File

@@ -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}');
});
});

View File

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

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

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

View File

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

View File

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

View File

@@ -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' });
});
});

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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