forked from Github/frigate
Compare commits
52 Commits
v0.10.0-be
...
v0.10.0-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1e84ca7fe | ||
|
|
e6ec5cb097 | ||
|
|
23c70acd51 | ||
|
|
091648187f | ||
|
|
2b7d38f947 | ||
|
|
f801930588 | ||
|
|
955c2779d9 | ||
|
|
037f8667a6 | ||
|
|
307068a61f | ||
|
|
077d900b44 | ||
|
|
92f9195075 | ||
|
|
82c60093d1 | ||
|
|
944b9181e0 | ||
|
|
326b368e82 | ||
|
|
040d8c9778 | ||
|
|
273f803c7c | ||
|
|
bd8e23833c | ||
|
|
9edf38347c | ||
|
|
1569ce7cf6 | ||
|
|
db1255aa7f | ||
|
|
609b436ed8 | ||
|
|
95bdf9fe34 | ||
|
|
251d29aa38 | ||
|
|
156e1a4dc2 | ||
|
|
a5c13e7455 | ||
|
|
fcb4aaef0d | ||
|
|
589432bc89 | ||
|
|
b19a02888a | ||
|
|
18fd50dfce | ||
|
|
df0246aed8 | ||
|
|
cbb2882123 | ||
|
|
9f18629df3 | ||
|
|
63f8034e46 | ||
|
|
f3efc0667f | ||
|
|
af001321a8 | ||
|
|
92e08b92f5 | ||
|
|
26241b0877 | ||
|
|
c1155af169 | ||
|
|
77c1f1bb1b | ||
|
|
ae3c01fe2d | ||
|
|
7a2a85d253 | ||
|
|
77c66d4e49 | ||
|
|
494e5ac4ec | ||
|
|
63b7465452 | ||
|
|
e6d2df5661 | ||
|
|
a3301e0347 | ||
|
|
3d556cc2cb | ||
|
|
585efe1a0f | ||
|
|
c7d47439dd | ||
|
|
19a6978228 | ||
|
|
1ebb8a54bf | ||
|
|
ae968044d6 |
24
.github/workflows/pull_request.yml
vendored
24
.github/workflows/pull_request.yml
vendored
@@ -44,3 +44,27 @@ jobs:
|
|||||||
- name: Test
|
- name: Test
|
||||||
run: npm run test
|
run: npm run test
|
||||||
working-directory: ./web
|
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"
|
||||||
|
|||||||
14
Makefile
14
Makefile
@@ -59,4 +59,16 @@ armv7_frigate: version web
|
|||||||
|
|
||||||
armv7_all: armv7_wheels armv7_ffmpeg armv7_frigate
|
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
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ http {
|
|||||||
|
|
||||||
# vod caches
|
# vod caches
|
||||||
vod_metadata_cache metadata_cache 512m;
|
vod_metadata_cache metadata_cache 512m;
|
||||||
vod_mapping_cache mapping_cache 5m;
|
vod_mapping_cache mapping_cache 5m 10m;
|
||||||
|
|
||||||
# gzip manifests
|
# gzip manifests
|
||||||
gzip on;
|
gzip on;
|
||||||
|
|||||||
@@ -159,8 +159,9 @@ detect:
|
|||||||
enabled: True
|
enabled: True
|
||||||
# Optional: Number of frames without a detection before frigate considers an object to be gone. (default: 5x the frame rate)
|
# Optional: Number of frames without a detection before frigate considers an object to be gone. (default: 5x the frame rate)
|
||||||
max_disappeared: 25
|
max_disappeared: 25
|
||||||
# Optional: Frequency for running detection on stationary objects (default: 10x the frame rate)
|
# Optional: Frequency for running detection on stationary objects (default: 0)
|
||||||
stationary_interval: 50
|
# 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
|
# Optional: Object configuration
|
||||||
# NOTE: Can be overridden at the camera level
|
# NOTE: Can be overridden at the camera level
|
||||||
@@ -224,15 +225,26 @@ motion:
|
|||||||
record:
|
record:
|
||||||
# Optional: Enable recording (default: shown below)
|
# Optional: Enable recording (default: shown below)
|
||||||
enabled: False
|
enabled: False
|
||||||
# Optional: Number of days to retain recordings regardless of events (default: shown below)
|
# Optional: Number of minutes to wait between cleanup runs (default: shown below)
|
||||||
# NOTE: This should be set to 0 and retention should be defined in events section below
|
# This can be used to reduce the frequency of deleting recording segments from disk if you want to minimize i/o
|
||||||
# if you only want to retain recordings of events.
|
expire_interval: 60
|
||||||
retain_days: 0
|
# 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
|
# Optional: Event recording settings
|
||||||
events:
|
events:
|
||||||
# Optional: Maximum length of time to retain video during long events. (default: shown below)
|
# 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
|
# 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
|
max_seconds: 300
|
||||||
# Optional: Number of seconds before the event to include (default: shown below)
|
# Optional: Number of seconds before the event to include (default: shown below)
|
||||||
pre_capture: 5
|
pre_capture: 5
|
||||||
@@ -247,6 +259,16 @@ record:
|
|||||||
retain:
|
retain:
|
||||||
# Required: Default retention days (default: shown below)
|
# Required: Default retention days (default: shown below)
|
||||||
default: 10
|
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
|
# Optional: Per object retention days
|
||||||
objects:
|
objects:
|
||||||
person: 15
|
person: 15
|
||||||
|
|||||||
@@ -14,12 +14,11 @@ If you only used clips in previous versions with recordings disabled, you can us
|
|||||||
```yaml
|
```yaml
|
||||||
record:
|
record:
|
||||||
enabled: True
|
enabled: True
|
||||||
retain_days: 0
|
|
||||||
events:
|
events:
|
||||||
retain:
|
retain:
|
||||||
default: 10
|
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`, segments will be deleted from the cache if no events are in progress
|
When `retain_days` is set to `0`, segments will be deleted from the cache if no events are in progress.
|
||||||
|
|||||||
@@ -67,13 +67,16 @@ class FrigateApp:
|
|||||||
|
|
||||||
def init_config(self):
|
def init_config(self):
|
||||||
config_file = os.environ.get("CONFIG_FILE", "/config/config.yml")
|
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)
|
user_config = FrigateConfig.parse_file(config_file)
|
||||||
self.config = user_config.runtime_config
|
self.config = user_config.runtime_config
|
||||||
|
|
||||||
for camera_name in self.config.cameras.keys():
|
for camera_name in self.config.cameras.keys():
|
||||||
# generage the ffmpeg commands
|
|
||||||
self.config.cameras[camera_name].create_ffmpeg_cmds()
|
|
||||||
|
|
||||||
# create camera_metrics
|
# create camera_metrics
|
||||||
self.camera_metrics[camera_name] = {
|
self.camera_metrics[camera_name] = {
|
||||||
"camera_fps": mp.Value("d", 0.0),
|
"camera_fps": mp.Value("d", 0.0),
|
||||||
@@ -108,6 +111,9 @@ class FrigateApp:
|
|||||||
maxsize=len(self.config.cameras.keys()) * 2
|
maxsize=len(self.config.cameras.keys()) * 2
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Queue for recordings info
|
||||||
|
self.recordings_info_queue = mp.Queue()
|
||||||
|
|
||||||
def init_database(self):
|
def init_database(self):
|
||||||
# Migrate DB location
|
# Migrate DB location
|
||||||
old_db_path = os.path.join(CLIPS_DIR, "frigate.db")
|
old_db_path = os.path.join(CLIPS_DIR, "frigate.db")
|
||||||
@@ -206,6 +212,7 @@ class FrigateApp:
|
|||||||
self.event_queue,
|
self.event_queue,
|
||||||
self.event_processed_queue,
|
self.event_processed_queue,
|
||||||
self.video_output_queue,
|
self.video_output_queue,
|
||||||
|
self.recordings_info_queue,
|
||||||
self.stop_event,
|
self.stop_event,
|
||||||
)
|
)
|
||||||
self.detected_frames_processor.start()
|
self.detected_frames_processor.start()
|
||||||
@@ -273,7 +280,9 @@ class FrigateApp:
|
|||||||
self.event_cleanup.start()
|
self.event_cleanup.start()
|
||||||
|
|
||||||
def start_recording_maintainer(self):
|
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()
|
self.recording_maintainer.start()
|
||||||
|
|
||||||
def start_recording_cleanup(self):
|
def start_recording_cleanup(self):
|
||||||
|
|||||||
@@ -12,9 +12,8 @@ import yaml
|
|||||||
from pydantic import BaseModel, Extra, Field, validator
|
from pydantic import BaseModel, Extra, Field, validator
|
||||||
from pydantic.fields import PrivateAttr
|
from pydantic.fields import PrivateAttr
|
||||||
|
|
||||||
from frigate.const import BASE_DIR, CACHE_DIR
|
from frigate.const import BASE_DIR, CACHE_DIR, YAML_EXT
|
||||||
from frigate.edgetpu import load_labels
|
from frigate.util import create_mask, deep_merge, load_labels
|
||||||
from frigate.util import create_mask, deep_merge
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -65,8 +64,15 @@ class MqttConfig(FrigateBaseModel):
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class RetainModeEnum(str, Enum):
|
||||||
|
all = "all"
|
||||||
|
motion = "motion"
|
||||||
|
active_objects = "active_objects"
|
||||||
|
|
||||||
|
|
||||||
class RetainConfig(FrigateBaseModel):
|
class RetainConfig(FrigateBaseModel):
|
||||||
default: float = Field(default=10, title="Default retention period.")
|
default: float = Field(default=10, title="Default retention period.")
|
||||||
|
mode: RetainModeEnum = Field(default=RetainModeEnum.motion, title="Retain mode.")
|
||||||
objects: Dict[str, float] = Field(
|
objects: Dict[str, float] = Field(
|
||||||
default_factory=dict, title="Object retention period."
|
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):
|
class RecordConfig(FrigateBaseModel):
|
||||||
enabled: bool = Field(default=False, title="Enable record on all cameras.")
|
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(
|
events: EventsConfig = Field(
|
||||||
default_factory=EventsConfig, title="Event specific settings."
|
default_factory=EventsConfig, title="Event specific settings."
|
||||||
)
|
)
|
||||||
@@ -154,7 +173,9 @@ class DetectConfig(FrigateBaseModel):
|
|||||||
title="Maximum number of frames the object can dissapear before detection ends."
|
title="Maximum number of frames the object can dissapear before detection ends."
|
||||||
)
|
)
|
||||||
stationary_interval: Optional[int] = Field(
|
stationary_interval: Optional[int] = Field(
|
||||||
title="Frame interval for checking stationary objects."
|
default=0,
|
||||||
|
title="Frame interval for checking stationary objects.",
|
||||||
|
ge=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -455,7 +476,7 @@ class CameraLiveConfig(FrigateBaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class CameraConfig(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.")
|
ffmpeg: CameraFfmpegConfig = Field(title="FFmpeg configuration for the camera.")
|
||||||
best_image_timeout: int = Field(
|
best_image_timeout: int = Field(
|
||||||
default=60,
|
default=60,
|
||||||
@@ -519,6 +540,8 @@ class CameraConfig(FrigateBaseModel):
|
|||||||
return self._ffmpeg_cmds
|
return self._ffmpeg_cmds
|
||||||
|
|
||||||
def create_ffmpeg_cmds(self):
|
def create_ffmpeg_cmds(self):
|
||||||
|
if "_ffmpeg_cmds" in self:
|
||||||
|
return
|
||||||
ffmpeg_cmds = []
|
ffmpeg_cmds = []
|
||||||
for ffmpeg_input in self.ffmpeg.inputs:
|
for ffmpeg_input in self.ffmpeg.inputs:
|
||||||
ffmpeg_cmd = self._get_ffmpeg_cmd(ffmpeg_input)
|
ffmpeg_cmd = self._get_ffmpeg_cmd(ffmpeg_input)
|
||||||
@@ -621,7 +644,7 @@ class ModelConfig(FrigateBaseModel):
|
|||||||
return self._merged_labelmap
|
return self._merged_labelmap
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def colormap(self) -> Dict[int, tuple[int, int, int]]:
|
def colormap(self) -> Dict[int, Tuple[int, int, int]]:
|
||||||
return self._colormap
|
return self._colormap
|
||||||
|
|
||||||
def __init__(self, **config):
|
def __init__(self, **config):
|
||||||
@@ -743,11 +766,6 @@ class FrigateConfig(FrigateBaseModel):
|
|||||||
if camera_config.detect.max_disappeared is None:
|
if camera_config.detect.max_disappeared is None:
|
||||||
camera_config.detect.max_disappeared = max_disappeared
|
camera_config.detect.max_disappeared = max_disappeared
|
||||||
|
|
||||||
# Default stationary_interval configuration
|
|
||||||
stationary_interval = camera_config.detect.fps * 10
|
|
||||||
if camera_config.detect.stationary_interval is None:
|
|
||||||
camera_config.detect.stationary_interval = stationary_interval
|
|
||||||
|
|
||||||
# FFMPEG input substitution
|
# FFMPEG input substitution
|
||||||
for input in camera_config.ffmpeg.inputs:
|
for input in camera_config.ffmpeg.inputs:
|
||||||
input.path = input.path.format(**FRIGATE_ENV_VARS)
|
input.path = input.path.format(**FRIGATE_ENV_VARS)
|
||||||
@@ -809,6 +827,26 @@ class FrigateConfig(FrigateBaseModel):
|
|||||||
f"Camera {name} has rtmp enabled, but rtmp is not assigned to an input."
|
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
|
config.cameras[name] = camera_config
|
||||||
|
|
||||||
return config
|
return config
|
||||||
@@ -826,7 +864,7 @@ class FrigateConfig(FrigateBaseModel):
|
|||||||
with open(config_file) as f:
|
with open(config_file) as f:
|
||||||
raw_config = f.read()
|
raw_config = f.read()
|
||||||
|
|
||||||
if config_file.endswith(".yml"):
|
if config_file.endswith(YAML_EXT):
|
||||||
config = yaml.safe_load(raw_config)
|
config = yaml.safe_load(raw_config)
|
||||||
elif config_file.endswith(".json"):
|
elif config_file.endswith(".json"):
|
||||||
config = json.loads(raw_config)
|
config = json.loads(raw_config)
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ BASE_DIR = "/media/frigate"
|
|||||||
CLIPS_DIR = f"{BASE_DIR}/clips"
|
CLIPS_DIR = f"{BASE_DIR}/clips"
|
||||||
RECORD_DIR = f"{BASE_DIR}/recordings"
|
RECORD_DIR = f"{BASE_DIR}/recordings"
|
||||||
CACHE_DIR = "/tmp/cache"
|
CACHE_DIR = "/tmp/cache"
|
||||||
|
YAML_EXT = (".yaml", ".yml")
|
||||||
|
|||||||
@@ -13,31 +13,11 @@ import tflite_runtime.interpreter as tflite
|
|||||||
from setproctitle import setproctitle
|
from setproctitle import setproctitle
|
||||||
from tflite_runtime.interpreter import load_delegate
|
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__)
|
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):
|
class ObjectDetector(ABC):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def detect(self, tensor_input, threshold=0.4):
|
def detect(self, tensor_input, threshold=0.4):
|
||||||
|
|||||||
@@ -133,6 +133,8 @@ def delete_event(id):
|
|||||||
if event.has_snapshot:
|
if event.has_snapshot:
|
||||||
media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg")
|
media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg")
|
||||||
media.unlink(missing_ok=True)
|
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:
|
if event.has_clip:
|
||||||
media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.mp4")
|
media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.mp4")
|
||||||
media.unlink(missing_ok=True)
|
media.unlink(missing_ok=True)
|
||||||
@@ -182,7 +184,7 @@ def event_thumbnail(id):
|
|||||||
thumbnail_bytes = jpg.tobytes()
|
thumbnail_bytes = jpg.tobytes()
|
||||||
|
|
||||||
response = make_response(thumbnail_bytes)
|
response = make_response(thumbnail_bytes)
|
||||||
response.headers["Content-Type"] = "image/jpg"
|
response.headers["Content-Type"] = "image/jpeg"
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@@ -223,7 +225,7 @@ def event_snapshot(id):
|
|||||||
return "Event not found", 404
|
return "Event not found", 404
|
||||||
|
|
||||||
response = make_response(jpg_bytes)
|
response = make_response(jpg_bytes)
|
||||||
response.headers["Content-Type"] = "image/jpg"
|
response.headers["Content-Type"] = "image/jpeg"
|
||||||
if download:
|
if download:
|
||||||
response.headers[
|
response.headers[
|
||||||
"Content-Disposition"
|
"Content-Disposition"
|
||||||
@@ -359,9 +361,10 @@ def best(camera_name, label):
|
|||||||
|
|
||||||
crop = bool(request.args.get("crop", 0, type=int))
|
crop = bool(request.args.get("crop", 0, type=int))
|
||||||
if crop:
|
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(
|
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]]
|
best_frame = best_frame[region[1] : region[3], region[0] : region[2]]
|
||||||
|
|
||||||
@@ -376,7 +379,7 @@ def best(camera_name, label):
|
|||||||
".jpg", best_frame, [int(cv2.IMWRITE_JPEG_QUALITY), resize_quality]
|
".jpg", best_frame, [int(cv2.IMWRITE_JPEG_QUALITY), resize_quality]
|
||||||
)
|
)
|
||||||
response = make_response(jpg.tobytes())
|
response = make_response(jpg.tobytes())
|
||||||
response.headers["Content-Type"] = "image/jpg"
|
response.headers["Content-Type"] = "image/jpeg"
|
||||||
return response
|
return response
|
||||||
else:
|
else:
|
||||||
return "Camera named {} not found".format(camera_name), 404
|
return "Camera named {} not found".format(camera_name), 404
|
||||||
@@ -438,7 +441,7 @@ def latest_frame(camera_name):
|
|||||||
".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), resize_quality]
|
".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), resize_quality]
|
||||||
)
|
)
|
||||||
response = make_response(jpg.tobytes())
|
response = make_response(jpg.tobytes())
|
||||||
response.headers["Content-Type"] = "image/jpg"
|
response.headers["Content-Type"] = "image/jpeg"
|
||||||
return response
|
return response
|
||||||
else:
|
else:
|
||||||
return "Camera named {} not found".format(camera_name), 404
|
return "Camera named {} not found".format(camera_name), 404
|
||||||
@@ -658,10 +661,15 @@ def vod_ts(camera, start_ts, end_ts):
|
|||||||
# Determine if we need to end the last clip early
|
# Determine if we need to end the last clip early
|
||||||
if recording.end_time > end_ts:
|
if recording.end_time > end_ts:
|
||||||
duration -= int((recording.end_time - end_ts) * 1000)
|
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:
|
if not clips:
|
||||||
|
logger.error("No recordings found for the requested time range")
|
||||||
return "No recordings found.", 404
|
return "No recordings found.", 404
|
||||||
|
|
||||||
hour_ago = datetime.now() - timedelta(hours=1)
|
hour_ago = datetime.now() - timedelta(hours=1)
|
||||||
@@ -690,10 +698,12 @@ def vod_event(id):
|
|||||||
try:
|
try:
|
||||||
event: Event = Event.get(Event.id == id)
|
event: Event = Event.get(Event.id == id)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
|
logger.error(f"Event not found: {id}")
|
||||||
return "Event not found.", 404
|
return "Event not found.", 404
|
||||||
|
|
||||||
if not event.has_clip:
|
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")
|
clip_path = os.path.join(CLIPS_DIR, f"{event.camera}-{id}.mp4")
|
||||||
|
|
||||||
|
|||||||
@@ -27,3 +27,5 @@ class Recordings(Model):
|
|||||||
start_time = DateTimeField()
|
start_time = DateTimeField()
|
||||||
end_time = DateTimeField()
|
end_time = DateTimeField()
|
||||||
duration = FloatField()
|
duration = FloatField()
|
||||||
|
motion = IntegerField(null=True)
|
||||||
|
objects = IntegerField(null=True)
|
||||||
|
|||||||
@@ -40,10 +40,12 @@ class MotionDetector:
|
|||||||
# Improve contrast
|
# Improve contrast
|
||||||
minval = np.percentile(resized_frame, 4)
|
minval = np.percentile(resized_frame, 4)
|
||||||
maxval = np.percentile(resized_frame, 96)
|
maxval = np.percentile(resized_frame, 96)
|
||||||
resized_frame = np.clip(resized_frame, minval, maxval)
|
# don't adjust if the image is a single color
|
||||||
resized_frame = (((resized_frame - minval) / (maxval - minval)) * 255).astype(
|
if minval < maxval:
|
||||||
np.uint8
|
resized_frame = np.clip(resized_frame, minval, maxval)
|
||||||
)
|
resized_frame = (
|
||||||
|
((resized_frame - minval) / (maxval - minval)) * 255
|
||||||
|
).astype(np.uint8)
|
||||||
|
|
||||||
# mask frame
|
# mask frame
|
||||||
resized_frame[self.mask] = [255]
|
resized_frame[self.mask] = [255]
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ def create_mqtt_client(config: FrigateConfig, camera_metrics):
|
|||||||
+ str(rc)
|
+ str(rc)
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("MQTT connected")
|
logger.debug("MQTT connected")
|
||||||
client.subscribe(f"{mqtt_config.topic_prefix}/#")
|
client.subscribe(f"{mqtt_config.topic_prefix}/#")
|
||||||
client.publish(mqtt_config.topic_prefix + "/available", "online", retain=True)
|
client.publish(mqtt_config.topic_prefix + "/available", "online", retain=True)
|
||||||
|
|
||||||
|
|||||||
@@ -18,12 +18,12 @@ import numpy as np
|
|||||||
|
|
||||||
from frigate.config import CameraConfig, SnapshotsConfig, RecordConfig, FrigateConfig
|
from frigate.config import CameraConfig, SnapshotsConfig, RecordConfig, FrigateConfig
|
||||||
from frigate.const import CACHE_DIR, CLIPS_DIR, RECORD_DIR
|
from frigate.const import CACHE_DIR, CLIPS_DIR, RECORD_DIR
|
||||||
from frigate.edgetpu import load_labels
|
|
||||||
from frigate.util import (
|
from frigate.util import (
|
||||||
SharedMemoryFrameManager,
|
SharedMemoryFrameManager,
|
||||||
calculate_region,
|
calculate_region,
|
||||||
draw_box_with_label,
|
draw_box_with_label,
|
||||||
draw_timestamp,
|
draw_timestamp,
|
||||||
|
load_labels,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -71,7 +71,7 @@ class TrackedObject:
|
|||||||
self.camera_config = camera_config
|
self.camera_config = camera_config
|
||||||
self.frame_cache = frame_cache
|
self.frame_cache = frame_cache
|
||||||
self.current_zones = []
|
self.current_zones = []
|
||||||
self.entered_zones = set()
|
self.entered_zones = []
|
||||||
self.false_positive = True
|
self.false_positive = True
|
||||||
self.has_clip = False
|
self.has_clip = False
|
||||||
self.has_snapshot = False
|
self.has_snapshot = False
|
||||||
@@ -147,7 +147,8 @@ class TrackedObject:
|
|||||||
# if the object passed the filters once, dont apply again
|
# if the object passed the filters once, dont apply again
|
||||||
if name in self.current_zones or not zone_filtered(self, zone.filters):
|
if name in self.current_zones or not zone_filtered(self, zone.filters):
|
||||||
current_zones.append(name)
|
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 the zones changed, signal an update
|
||||||
if not self.false_positive and set(self.current_zones) != set(current_zones):
|
if not self.false_positive and set(self.current_zones) != set(current_zones):
|
||||||
@@ -176,8 +177,10 @@ class TrackedObject:
|
|||||||
"box": self.obj_data["box"],
|
"box": self.obj_data["box"],
|
||||||
"area": self.obj_data["area"],
|
"area": self.obj_data["area"],
|
||||||
"region": self.obj_data["region"],
|
"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(),
|
"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_clip": self.has_clip,
|
||||||
"has_snapshot": self.has_snapshot,
|
"has_snapshot": self.has_snapshot,
|
||||||
}
|
}
|
||||||
@@ -262,8 +265,15 @@ class TrackedObject:
|
|||||||
|
|
||||||
if crop:
|
if crop:
|
||||||
box = self.thumbnail_data["box"]
|
box = self.thumbnail_data["box"]
|
||||||
|
box_size = 300
|
||||||
region = calculate_region(
|
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]]
|
best_frame = best_frame[region[1] : region[3], region[0] : region[2]]
|
||||||
|
|
||||||
@@ -584,6 +594,7 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
event_queue,
|
event_queue,
|
||||||
event_processed_queue,
|
event_processed_queue,
|
||||||
video_output_queue,
|
video_output_queue,
|
||||||
|
recordings_info_queue,
|
||||||
stop_event,
|
stop_event,
|
||||||
):
|
):
|
||||||
threading.Thread.__init__(self)
|
threading.Thread.__init__(self)
|
||||||
@@ -595,6 +606,7 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
self.event_queue = event_queue
|
self.event_queue = event_queue
|
||||||
self.event_processed_queue = event_processed_queue
|
self.event_processed_queue = event_processed_queue
|
||||||
self.video_output_queue = video_output_queue
|
self.video_output_queue = video_output_queue
|
||||||
|
self.recordings_info_queue = recordings_info_queue
|
||||||
self.stop_event = stop_event
|
self.stop_event = stop_event
|
||||||
self.camera_states: Dict[str, CameraState] = {}
|
self.camera_states: Dict[str, CameraState] = {}
|
||||||
self.frame_manager = SharedMemoryFrameManager()
|
self.frame_manager = SharedMemoryFrameManager()
|
||||||
@@ -727,9 +739,13 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
if not snapshot_config.enabled:
|
if not snapshot_config.enabled:
|
||||||
return False
|
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
|
# if there are required zones and there is no overlap
|
||||||
required_zones = snapshot_config.required_zones
|
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(
|
logger.debug(
|
||||||
f"Not creating snapshot for {obj.obj_data['id']} because it did not enter required zones"
|
f"Not creating snapshot for {obj.obj_data['id']} because it did not enter required zones"
|
||||||
)
|
)
|
||||||
@@ -747,6 +763,10 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
if not record_config.enabled:
|
if not record_config.enabled:
|
||||||
return False
|
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
|
# If there are required zones and there is no overlap
|
||||||
required_zones = record_config.events.required_zones
|
required_zones = record_config.events.required_zones
|
||||||
if len(required_zones) > 0 and not set(obj.entered_zones) & set(required_zones):
|
if len(required_zones) > 0 and not set(obj.entered_zones) & set(required_zones):
|
||||||
@@ -768,9 +788,13 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def should_mqtt_snapshot(self, camera, obj: TrackedObject):
|
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
|
# if there are required zones and there is no overlap
|
||||||
required_zones = self.config.cameras[camera].mqtt.required_zones
|
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(
|
logger.debug(
|
||||||
f"Not sending mqtt for {obj.obj_data['id']} because it did not enter required zones"
|
f"Not sending mqtt for {obj.obj_data['id']} because it did not enter required zones"
|
||||||
)
|
)
|
||||||
@@ -813,11 +837,26 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
frame_time, current_tracked_objects, motion_boxes, regions
|
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(
|
self.video_output_queue.put(
|
||||||
(
|
(
|
||||||
camera,
|
camera,
|
||||||
frame_time,
|
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,
|
motion_boxes,
|
||||||
regions,
|
regions,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ class ObjectTracker:
|
|||||||
def __init__(self, config: DetectConfig):
|
def __init__(self, config: DetectConfig):
|
||||||
self.tracked_objects = {}
|
self.tracked_objects = {}
|
||||||
self.disappeared = {}
|
self.disappeared = {}
|
||||||
|
self.positions = {}
|
||||||
self.max_disappeared = config.max_disappeared
|
self.max_disappeared = config.max_disappeared
|
||||||
|
self.detect_config = config
|
||||||
|
|
||||||
def register(self, index, obj):
|
def register(self, index, obj):
|
||||||
rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
|
rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
|
||||||
@@ -28,24 +30,83 @@ class ObjectTracker:
|
|||||||
obj["id"] = id
|
obj["id"] = id
|
||||||
obj["start_time"] = obj["frame_time"]
|
obj["start_time"] = obj["frame_time"]
|
||||||
obj["motionless_count"] = 0
|
obj["motionless_count"] = 0
|
||||||
|
obj["position_changes"] = 0
|
||||||
self.tracked_objects[id] = obj
|
self.tracked_objects[id] = obj
|
||||||
self.disappeared[id] = 0
|
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):
|
def deregister(self, id):
|
||||||
del self.tracked_objects[id]
|
del self.tracked_objects[id]
|
||||||
del self.disappeared[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):
|
def update(self, id, new_obj):
|
||||||
self.disappeared[id] = 0
|
self.disappeared[id] = 0
|
||||||
if (
|
# update the motionless count if the object has not moved to a new position
|
||||||
intersection_over_union(self.tracked_objects[id]["box"], new_obj["box"])
|
if self.update_position(id, new_obj["box"]):
|
||||||
> 0.9
|
|
||||||
):
|
|
||||||
self.tracked_objects[id]["motionless_count"] += 1
|
self.tracked_objects[id]["motionless_count"] += 1
|
||||||
else:
|
else:
|
||||||
self.tracked_objects[id]["motionless_count"] = 0
|
self.tracked_objects[id]["motionless_count"] = 0
|
||||||
|
self.tracked_objects[id]["position_changes"] += 1
|
||||||
self.tracked_objects[id].update(new_obj)
|
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):
|
def match_and_update(self, frame_time, new_objects):
|
||||||
# group by name
|
# group by name
|
||||||
new_object_groups = defaultdict(lambda: [])
|
new_object_groups = defaultdict(lambda: [])
|
||||||
|
|||||||
@@ -1,22 +1,25 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import time
|
|
||||||
import itertools
|
import itertools
|
||||||
import logging
|
import logging
|
||||||
|
import multiprocessing as mp
|
||||||
import os
|
import os
|
||||||
|
import queue
|
||||||
import random
|
import random
|
||||||
import shutil
|
import shutil
|
||||||
import string
|
import string
|
||||||
import subprocess as sp
|
import subprocess as sp
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
from peewee import JOIN, DoesNotExist
|
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.const import CACHE_DIR, RECORD_DIR
|
||||||
from frigate.models import Event, Recordings
|
from frigate.models import Event, Recordings
|
||||||
|
from frigate.util import area
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -40,20 +43,28 @@ def remove_empty_directories(directory):
|
|||||||
|
|
||||||
|
|
||||||
class RecordingMaintainer(threading.Thread):
|
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)
|
threading.Thread.__init__(self)
|
||||||
self.name = "recording_maint"
|
self.name = "recording_maint"
|
||||||
self.config = config
|
self.config = config
|
||||||
|
self.recordings_info_queue = recordings_info_queue
|
||||||
self.stop_event = stop_event
|
self.stop_event = stop_event
|
||||||
|
self.first_pass = True
|
||||||
|
self.recordings_info = defaultdict(list)
|
||||||
|
self.end_time_cache = {}
|
||||||
|
|
||||||
def move_files(self):
|
def move_files(self):
|
||||||
cache_files = [
|
cache_files = sorted(
|
||||||
d
|
[
|
||||||
for d in os.listdir(CACHE_DIR)
|
d
|
||||||
if os.path.isfile(os.path.join(CACHE_DIR, d))
|
for d in os.listdir(CACHE_DIR)
|
||||||
and d.endswith(".mp4")
|
if os.path.isfile(os.path.join(CACHE_DIR, d))
|
||||||
and not d.startswith("clip_")
|
and d.endswith(".mp4")
|
||||||
]
|
and not d.startswith("clip_")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
files_in_use = []
|
files_in_use = []
|
||||||
for process in psutil.process_iter():
|
for process in psutil.process_iter():
|
||||||
@@ -87,21 +98,26 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# delete all cached files past the most recent 2
|
# delete all cached files past the most recent 5
|
||||||
|
keep_count = 5
|
||||||
for camera in grouped_recordings.keys():
|
for camera in grouped_recordings.keys():
|
||||||
if len(grouped_recordings[camera]) > 2:
|
if len(grouped_recordings[camera]) > keep_count:
|
||||||
logger.warning(
|
to_remove = grouped_recordings[camera][:-keep_count]
|
||||||
"Proactively cleaning cache. Your recordings disk may be too slow."
|
|
||||||
)
|
|
||||||
sorted_recordings = sorted(
|
|
||||||
grouped_recordings[camera], key=lambda i: i["start_time"]
|
|
||||||
)
|
|
||||||
to_remove = sorted_recordings[:-2]
|
|
||||||
for f in to_remove:
|
for f in to_remove:
|
||||||
Path(f["cache_path"]).unlink(missing_ok=True)
|
Path(f["cache_path"]).unlink(missing_ok=True)
|
||||||
grouped_recordings[camera] = sorted_recordings[-2:]
|
self.end_time_cache.pop(f["cache_path"], None)
|
||||||
|
grouped_recordings[camera] = grouped_recordings[camera][-keep_count:]
|
||||||
|
|
||||||
for camera, recordings in grouped_recordings.items():
|
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
|
# get all events with the end time after the start of the oldest cache file
|
||||||
# or with end_time None
|
# or with end_time None
|
||||||
events: Event = (
|
events: Event = (
|
||||||
@@ -109,7 +125,7 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
.where(
|
.where(
|
||||||
Event.camera == camera,
|
Event.camera == camera,
|
||||||
(Event.end_time == None)
|
(Event.end_time == None)
|
||||||
| (Event.end_time >= recordings[0]["start_time"]),
|
| (Event.end_time >= recordings[0]["start_time"].timestamp()),
|
||||||
Event.has_clip,
|
Event.has_clip,
|
||||||
)
|
)
|
||||||
.order_by(Event.start_time)
|
.order_by(Event.start_time)
|
||||||
@@ -124,33 +140,38 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
or not self.config.cameras[camera].record.enabled
|
or not self.config.cameras[camera].record.enabled
|
||||||
):
|
):
|
||||||
Path(cache_path).unlink(missing_ok=True)
|
Path(cache_path).unlink(missing_ok=True)
|
||||||
|
self.end_time_cache.pop(cache_path, None)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
ffprobe_cmd = [
|
if cache_path in self.end_time_cache:
|
||||||
"ffprobe",
|
end_time, duration = self.end_time_cache[cache_path]
|
||||||
"-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:
|
else:
|
||||||
logger.warning(f"Discarding a corrupt recording segment: {f}")
|
ffprobe_cmd = [
|
||||||
Path(cache_path).unlink(missing_ok=True)
|
"ffprobe",
|
||||||
continue
|
"-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 cached file's start_time is earlier than the retain days for the camera
|
||||||
if start_time <= (
|
if start_time <= (
|
||||||
(
|
(
|
||||||
datetime.datetime.now()
|
datetime.datetime.now()
|
||||||
- datetime.timedelta(
|
- datetime.timedelta(
|
||||||
days=self.config.cameras[camera].record.retain_days
|
days=self.config.cameras[camera].record.retain.days
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
@@ -158,18 +179,26 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
overlaps = False
|
overlaps = False
|
||||||
for event in events:
|
for event in events:
|
||||||
# if the event starts in the future, stop checking events
|
# if the event starts in the future, stop checking events
|
||||||
# and let this recording segment expire
|
# and remove this segment
|
||||||
if event.start_time > end_time.timestamp():
|
if event.start_time > end_time.timestamp():
|
||||||
overlaps = False
|
overlaps = False
|
||||||
|
Path(cache_path).unlink(missing_ok=True)
|
||||||
|
self.end_time_cache.pop(cache_path, None)
|
||||||
break
|
break
|
||||||
|
|
||||||
# if the event is in progress or 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
|
# and stop looking at events
|
||||||
if event.end_time is None or event.end_time >= start_time:
|
if (
|
||||||
|
event.end_time is None
|
||||||
|
or event.end_time >= start_time.timestamp()
|
||||||
|
):
|
||||||
overlaps = True
|
overlaps = True
|
||||||
break
|
break
|
||||||
|
|
||||||
if overlaps:
|
if overlaps:
|
||||||
|
record_mode = self.config.cameras[
|
||||||
|
camera
|
||||||
|
].record.events.retain.mode
|
||||||
# move from cache to recordings immediately
|
# move from cache to recordings immediately
|
||||||
self.store_segment(
|
self.store_segment(
|
||||||
camera,
|
camera,
|
||||||
@@ -177,14 +206,57 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
end_time,
|
end_time,
|
||||||
duration,
|
duration,
|
||||||
cache_path,
|
cache_path,
|
||||||
|
record_mode,
|
||||||
)
|
)
|
||||||
# else retain_days includes this segment
|
# else retain days includes this segment
|
||||||
else:
|
else:
|
||||||
|
record_mode = self.config.cameras[camera].record.retain.mode
|
||||||
self.store_segment(
|
self.store_segment(
|
||||||
camera, start_time, end_time, duration, cache_path
|
camera, start_time, end_time, duration, cache_path, record_mode
|
||||||
)
|
)
|
||||||
|
|
||||||
def store_segment(self, camera, start_time, end_time, duration, cache_path):
|
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)
|
directory = os.path.join(RECORD_DIR, start_time.strftime("%Y-%m/%d/%H"), camera)
|
||||||
|
|
||||||
if not os.path.exists(directory):
|
if not os.path.exists(directory):
|
||||||
@@ -212,17 +284,46 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
start_time=start_time.timestamp(),
|
start_time=start_time.timestamp(),
|
||||||
end_time=end_time.timestamp(),
|
end_time=end_time.timestamp(),
|
||||||
duration=duration,
|
duration=duration,
|
||||||
|
motion=motion_count,
|
||||||
|
objects=active_count,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Unable to store recording segment {cache_path}")
|
logger.error(f"Unable to store recording segment {cache_path}")
|
||||||
Path(cache_path).unlink(missing_ok=True)
|
Path(cache_path).unlink(missing_ok=True)
|
||||||
logger.error(e)
|
logger.error(e)
|
||||||
|
|
||||||
|
# clear end_time cache
|
||||||
|
self.end_time_cache.pop(cache_path, None)
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
# Check for new files every 5 seconds
|
# Check for new files every 5 seconds
|
||||||
wait_time = 5
|
wait_time = 5
|
||||||
while not self.stop_event.wait(wait_time):
|
while not self.stop_event.wait(wait_time):
|
||||||
run_start = datetime.datetime.now().timestamp()
|
run_start = datetime.datetime.now().timestamp()
|
||||||
|
|
||||||
|
# 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:
|
try:
|
||||||
self.move_files()
|
self.move_files()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -230,7 +331,14 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
"Error occurred when attempting to maintain recording cache"
|
"Error occurred when attempting to maintain recording cache"
|
||||||
)
|
)
|
||||||
logger.error(e)
|
logger.error(e)
|
||||||
wait_time = max(0, 5 - (datetime.datetime.now().timestamp() - run_start))
|
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...")
|
logger.info(f"Exiting recording maintenance...")
|
||||||
|
|
||||||
@@ -255,7 +363,7 @@ class RecordingCleanup(threading.Thread):
|
|||||||
|
|
||||||
logger.debug("Start deleted cameras.")
|
logger.debug("Start deleted cameras.")
|
||||||
# Handle deleted cameras
|
# Handle deleted cameras
|
||||||
expire_days = self.config.record.retain_days
|
expire_days = self.config.record.retain.days
|
||||||
expire_before = (
|
expire_before = (
|
||||||
datetime.datetime.now() - datetime.timedelta(days=expire_days)
|
datetime.datetime.now() - datetime.timedelta(days=expire_days)
|
||||||
).timestamp()
|
).timestamp()
|
||||||
@@ -281,7 +389,7 @@ class RecordingCleanup(threading.Thread):
|
|||||||
datetime.datetime.now()
|
datetime.datetime.now()
|
||||||
- datetime.timedelta(seconds=config.record.events.max_seconds)
|
- datetime.timedelta(seconds=config.record.events.max_seconds)
|
||||||
).timestamp()
|
).timestamp()
|
||||||
expire_days = config.record.retain_days
|
expire_days = config.record.retain.days
|
||||||
expire_before = (
|
expire_before = (
|
||||||
datetime.datetime.now() - datetime.timedelta(days=expire_days)
|
datetime.datetime.now() - datetime.timedelta(days=expire_days)
|
||||||
).timestamp()
|
).timestamp()
|
||||||
@@ -312,6 +420,7 @@ class RecordingCleanup(threading.Thread):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# loop over recordings and see if they overlap with any non-expired events
|
# 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
|
event_start = 0
|
||||||
deleted_recordings = set()
|
deleted_recordings = set()
|
||||||
for recording in recordings.objects().iterator():
|
for recording in recordings.objects().iterator():
|
||||||
@@ -339,8 +448,19 @@ class RecordingCleanup(threading.Thread):
|
|||||||
if event.end_time < recording.start_time:
|
if event.end_time < recording.start_time:
|
||||||
event_start = idx
|
event_start = idx
|
||||||
|
|
||||||
# Delete recordings outside of the retention window
|
# Delete recordings outside of the retention window or based on the retention mode
|
||||||
if not keep:
|
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)
|
Path(recording.path).unlink(missing_ok=True)
|
||||||
deleted_recordings.add(recording.id)
|
deleted_recordings.add(recording.id)
|
||||||
|
|
||||||
@@ -357,14 +477,14 @@ class RecordingCleanup(threading.Thread):
|
|||||||
|
|
||||||
default_expire = (
|
default_expire = (
|
||||||
datetime.datetime.now().timestamp()
|
datetime.datetime.now().timestamp()
|
||||||
- SECONDS_IN_DAY * self.config.record.retain_days
|
- SECONDS_IN_DAY * self.config.record.retain.days
|
||||||
)
|
)
|
||||||
delete_before = {}
|
delete_before = {}
|
||||||
|
|
||||||
for name, camera in self.config.cameras.items():
|
for name, camera in self.config.cameras.items():
|
||||||
delete_before[name] = (
|
delete_before[name] = (
|
||||||
datetime.datetime.now().timestamp()
|
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
|
# find all the recordings older than the oldest recording in the db
|
||||||
@@ -377,7 +497,8 @@ class RecordingCleanup(threading.Thread):
|
|||||||
oldest_timestamp = datetime.datetime.now().timestamp()
|
oldest_timestamp = datetime.datetime.now().timestamp()
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
logger.warning(f"Unable to find file from recordings database: {p}")
|
logger.warning(f"Unable to find file from recordings database: {p}")
|
||||||
oldest_timestamp = datetime.datetime.now().timestamp()
|
Recordings.delete().where(Recordings.id == oldest_recording.id).execute()
|
||||||
|
return
|
||||||
|
|
||||||
logger.debug(f"Oldest recording in the db: {oldest_timestamp}")
|
logger.debug(f"Oldest recording in the db: {oldest_timestamp}")
|
||||||
process = sp.run(
|
process = sp.run(
|
||||||
@@ -389,21 +510,52 @@ class RecordingCleanup(threading.Thread):
|
|||||||
|
|
||||||
for f in files_to_check:
|
for f in files_to_check:
|
||||||
p = Path(f)
|
p = Path(f)
|
||||||
if p.stat().st_mtime < delete_before.get(p.parent.name, default_expire):
|
try:
|
||||||
p.unlink(missing_ok=True)
|
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).")
|
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):
|
def run(self):
|
||||||
# Expire recordings every minute, clean directories every hour.
|
# on startup sync recordings with disk (disabled due to too much CPU usage)
|
||||||
for counter in itertools.cycle(range(60)):
|
# 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):
|
if self.stop_event.wait(60):
|
||||||
logger.info(f"Exiting recording cleanup...")
|
logger.info(f"Exiting recording cleanup...")
|
||||||
break
|
break
|
||||||
|
|
||||||
self.expire_recordings()
|
|
||||||
self.clean_tmp_clips()
|
self.clean_tmp_clips()
|
||||||
|
|
||||||
if counter == 0:
|
if counter == 0:
|
||||||
|
self.expire_recordings()
|
||||||
self.expire_files()
|
self.expire_files()
|
||||||
remove_empty_directories(RECORD_DIR)
|
remove_empty_directories(RECORD_DIR)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import threading
|
|||||||
import time
|
import time
|
||||||
import psutil
|
import psutil
|
||||||
import shutil
|
import shutil
|
||||||
|
import os
|
||||||
|
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.const import RECORD_DIR, CLIPS_DIR, CACHE_DIR
|
from frigate.const import RECORD_DIR, CLIPS_DIR, CACHE_DIR
|
||||||
@@ -31,6 +32,28 @@ def get_fs_type(path):
|
|||||||
return fsType
|
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):
|
def stats_snapshot(stats_tracking):
|
||||||
camera_metrics = stats_tracking["camera_metrics"]
|
camera_metrics = stats_tracking["camera_metrics"]
|
||||||
stats = {}
|
stats = {}
|
||||||
@@ -61,6 +84,7 @@ def stats_snapshot(stats_tracking):
|
|||||||
"uptime": (int(time.time()) - stats_tracking["started"]),
|
"uptime": (int(time.time()) - stats_tracking["started"]),
|
||||||
"version": VERSION,
|
"version": VERSION,
|
||||||
"storage": {},
|
"storage": {},
|
||||||
|
"temperatures": get_temperatures(),
|
||||||
}
|
}
|
||||||
|
|
||||||
for path in [RECORD_DIR, CLIPS_DIR, CACHE_DIR, "/dev/shm"]:
|
for path in [RECORD_DIR, CLIPS_DIR, CACHE_DIR, "/dev/shm"]:
|
||||||
|
|||||||
@@ -572,7 +572,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.dict(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config
|
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):
|
def test_motion_contour_area_dynamic(self):
|
||||||
|
|
||||||
@@ -601,7 +601,7 @@ class TestConfig(unittest.TestCase):
|
|||||||
assert config == frigate_config.dict(exclude_unset=True)
|
assert config == frigate_config.dict(exclude_unset=True)
|
||||||
|
|
||||||
runtime_config = frigate_config.runtime_config
|
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):
|
def test_merge_labelmap(self):
|
||||||
|
|
||||||
@@ -1244,6 +1244,30 @@ class TestConfig(unittest.TestCase):
|
|||||||
runtime_config = frigate_config.runtime_config
|
runtime_config = frigate_config.runtime_config
|
||||||
assert runtime_config.cameras["back"].snapshots.retain.default == 1.5
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main(verbosity=2)
|
unittest.main(verbosity=2)
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import cv2
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from unittest import TestCase, main
|
from unittest import TestCase, main
|
||||||
from frigate.video import box_overlaps, reduce_boxes
|
from frigate.video import box_overlaps, reduce_boxes
|
||||||
|
|||||||
@@ -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 is the longest edge and divisible by 4
|
||||||
size = int((max(xmax - xmin, ymax - ymin) * multiplier) // 4 * 4)
|
size = int((max(xmax - xmin, ymax - ymin) * multiplier) // 4 * 4)
|
||||||
# dont go any smaller than 300
|
# dont go any smaller than the model_size
|
||||||
if size < 300:
|
if size < model_size:
|
||||||
size = 300
|
size = model_size
|
||||||
|
|
||||||
# x_offset is midpoint of bounding box minus half the size
|
# x_offset is midpoint of bounding box minus half the size
|
||||||
x_offset = int((xmax - xmin) / 2.0 + xmin - size / 2.0)
|
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))
|
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):
|
class FrameManager(ABC):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
|||||||
134
frigate/video.py
134
frigate/video.py
@@ -3,6 +3,7 @@ import itertools
|
|||||||
import logging
|
import logging
|
||||||
import multiprocessing as mp
|
import multiprocessing as mp
|
||||||
import queue
|
import queue
|
||||||
|
import random
|
||||||
import signal
|
import signal
|
||||||
import subprocess as sp
|
import subprocess as sp
|
||||||
import threading
|
import threading
|
||||||
@@ -75,25 +76,7 @@ def filtered(obj, objects_to_track, object_filters):
|
|||||||
|
|
||||||
|
|
||||||
def create_tensor_input(frame, model_shape, region):
|
def create_tensor_input(frame, model_shape, region):
|
||||||
# TODO: is it faster to just convert grayscale to RGB? or repeat dimensions with numpy?
|
cropped_frame = yuv_region_2_rgb(frame, region)
|
||||||
height = frame.shape[0] // 3 * 2
|
|
||||||
width = frame.shape[1]
|
|
||||||
|
|
||||||
# get the crop box if the region extends beyond the frame
|
|
||||||
crop_x1 = max(0, region[0])
|
|
||||||
crop_y1 = max(0, region[1])
|
|
||||||
crop_x2 = min(width, region[2])
|
|
||||||
crop_y2 = min(height, region[3])
|
|
||||||
|
|
||||||
size = region[3] - region[1]
|
|
||||||
cropped_frame = np.zeros((size, size), np.uint8)
|
|
||||||
|
|
||||||
cropped_frame[
|
|
||||||
0 : crop_y2 - crop_y1,
|
|
||||||
0 : crop_x2 - crop_x1,
|
|
||||||
] = frame[crop_y1:crop_y2, crop_x1:crop_x2]
|
|
||||||
|
|
||||||
cropped_frame = np.repeat(np.expand_dims(cropped_frame, -1), 3, 2)
|
|
||||||
|
|
||||||
# Resize to 300x300 if needed
|
# Resize to 300x300 if needed
|
||||||
if cropped_frame.shape != (model_shape[0], model_shape[1], 3):
|
if cropped_frame.shape != (model_shape[0], model_shape[1], 3):
|
||||||
@@ -487,6 +470,8 @@ def process_frames(
|
|||||||
fps_tracker = EventsPerSecond()
|
fps_tracker = EventsPerSecond()
|
||||||
fps_tracker.start()
|
fps_tracker.start()
|
||||||
|
|
||||||
|
startup_scan_counter = 0
|
||||||
|
|
||||||
while not stop_event.is_set():
|
while not stop_event.is_set():
|
||||||
if exit_on_empty and frame_queue.empty():
|
if exit_on_empty and frame_queue.empty():
|
||||||
logger.info(f"Exiting track_objects...")
|
logger.info(f"Exiting track_objects...")
|
||||||
@@ -530,7 +515,10 @@ def process_frames(
|
|||||||
# if there hasn't been motion for 10 frames
|
# if there hasn't been motion for 10 frames
|
||||||
if obj["motionless_count"] >= 10
|
if obj["motionless_count"] >= 10
|
||||||
# and it isn't due for a periodic check
|
# and it isn't due for a periodic check
|
||||||
and obj["motionless_count"] % detect_config.stationary_interval != 0
|
and (
|
||||||
|
detect_config.stationary_interval == 0
|
||||||
|
or obj["motionless_count"] % detect_config.stationary_interval != 0
|
||||||
|
)
|
||||||
# and it hasn't disappeared
|
# and it hasn't disappeared
|
||||||
and object_tracker.disappeared[obj["id"]] == 0
|
and object_tracker.disappeared[obj["id"]] == 0
|
||||||
# and it doesn't overlap with any current motion boxes
|
# and it doesn't overlap with any current motion boxes
|
||||||
@@ -547,18 +535,42 @@ def process_frames(
|
|||||||
# combine motion boxes with known locations of existing objects
|
# combine motion boxes with known locations of existing objects
|
||||||
combined_boxes = reduce_boxes(motion_boxes + tracked_object_boxes)
|
combined_boxes = reduce_boxes(motion_boxes + tracked_object_boxes)
|
||||||
|
|
||||||
|
region_min_size = max(model_shape[0], model_shape[1])
|
||||||
# compute regions
|
# compute regions
|
||||||
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
|
for a in combined_boxes
|
||||||
]
|
]
|
||||||
|
|
||||||
# consolidate regions with heavy overlap
|
# consolidate regions with heavy overlap
|
||||||
regions = [
|
regions = [
|
||||||
calculate_region(frame_shape, a[0], a[1], a[2], a[3], 1.0)
|
calculate_region(
|
||||||
|
frame_shape, a[0], a[1], a[2], a[3], region_min_size, multiplier=1.0
|
||||||
|
)
|
||||||
for a in reduce_boxes(regions, 0.4)
|
for 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
|
# resize regions and detect
|
||||||
# seed with stationary objects
|
# seed with stationary objects
|
||||||
detections = [
|
detections = [
|
||||||
@@ -572,6 +584,7 @@ def process_frames(
|
|||||||
for obj in object_tracker.tracked_objects.values()
|
for obj in object_tracker.tracked_objects.values()
|
||||||
if obj["id"] in stationary_object_ids
|
if obj["id"] in stationary_object_ids
|
||||||
]
|
]
|
||||||
|
|
||||||
for region in regions:
|
for region in regions:
|
||||||
detections.extend(
|
detections.extend(
|
||||||
detect(
|
detect(
|
||||||
@@ -587,7 +600,7 @@ def process_frames(
|
|||||||
#########
|
#########
|
||||||
# merge objects, check for clipped objects and look again up to 4 times
|
# merge objects, check for clipped objects and look again up to 4 times
|
||||||
#########
|
#########
|
||||||
refining = True
|
refining = len(regions) > 0
|
||||||
refine_count = 0
|
refine_count = 0
|
||||||
while refining and refine_count < 4:
|
while refining and refine_count < 4:
|
||||||
refining = False
|
refining = False
|
||||||
@@ -614,7 +627,7 @@ def process_frames(
|
|||||||
box = obj[2]
|
box = obj[2]
|
||||||
# calculate a new region that will hopefully get the entire object
|
# calculate a new region that will hopefully get the entire object
|
||||||
region = calculate_region(
|
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)
|
regions.append(region)
|
||||||
@@ -642,44 +655,49 @@ def process_frames(
|
|||||||
|
|
||||||
## drop detections that overlap too much
|
## drop detections that overlap too much
|
||||||
consolidated_detections = []
|
consolidated_detections = []
|
||||||
# group by name
|
|
||||||
detected_object_groups = defaultdict(lambda: [])
|
|
||||||
for detection in detections:
|
|
||||||
detected_object_groups[detection[0]].append(detection)
|
|
||||||
|
|
||||||
# loop over detections grouped by label
|
# if detection was run on this frame, consolidate
|
||||||
for group in detected_object_groups.values():
|
if len(regions) > 0:
|
||||||
# if the group only has 1 item, skip
|
# group by name
|
||||||
if len(group) == 1:
|
detected_object_groups = defaultdict(lambda: [])
|
||||||
consolidated_detections.append(group[0])
|
for detection in detections:
|
||||||
continue
|
detected_object_groups[detection[0]].append(detection)
|
||||||
|
|
||||||
# sort smallest to largest by area
|
# loop over detections grouped by label
|
||||||
sorted_by_area = sorted(group, key=lambda g: g[3])
|
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
|
||||||
|
|
||||||
for current_detection_idx in range(0, len(sorted_by_area)):
|
# sort smallest to largest by area
|
||||||
current_detection = sorted_by_area[current_detection_idx][2]
|
sorted_by_area = sorted(group, key=lambda g: g[3])
|
||||||
overlap = 0
|
|
||||||
for to_check_idx in range(
|
for current_detection_idx in range(0, len(sorted_by_area)):
|
||||||
min(current_detection_idx + 1, len(sorted_by_area)),
|
current_detection = sorted_by_area[current_detection_idx][2]
|
||||||
len(sorted_by_area),
|
overlap = 0
|
||||||
):
|
for to_check_idx in range(
|
||||||
to_check = sorted_by_area[to_check_idx][2]
|
min(current_detection_idx + 1, len(sorted_by_area)),
|
||||||
# if 90% of smaller detection is inside of another detection, consolidate
|
len(sorted_by_area),
|
||||||
if (
|
|
||||||
area(intersection(current_detection, to_check))
|
|
||||||
/ area(current_detection)
|
|
||||||
> 0.9
|
|
||||||
):
|
):
|
||||||
overlap = 1
|
to_check = sorted_by_area[to_check_idx][2]
|
||||||
break
|
# if 90% of smaller detection is inside of another detection, consolidate
|
||||||
if overlap == 0:
|
if (
|
||||||
consolidated_detections.append(
|
area(intersection(current_detection, to_check))
|
||||||
sorted_by_area[current_detection_idx]
|
/ area(current_detection)
|
||||||
)
|
> 0.9
|
||||||
|
):
|
||||||
# now that we have refined our detections, we need to track objects
|
overlap = 1
|
||||||
object_tracker.match_and_update(frame_time, consolidated_detections)
|
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
|
# add to the queue if not full
|
||||||
if detected_objects_queue.full():
|
if detected_objects_queue.full():
|
||||||
|
|||||||
@@ -28,17 +28,19 @@ SQL = pw.SQL
|
|||||||
|
|
||||||
|
|
||||||
def migrate(migrator, database, fake=False, **kwargs):
|
def migrate(migrator, database, fake=False, **kwargs):
|
||||||
migrator.create_model(Recordings)
|
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)'
|
||||||
def add_index():
|
)
|
||||||
# First add the index here, because there is a bug in peewee_migrate
|
migrator.sql(
|
||||||
# when trying to create an multi-column index in the same migration
|
'CREATE INDEX IF NOT EXISTS "recordings_camera" ON "recordings" ("camera")'
|
||||||
# as the table: https://github.com/klen/peewee_migrate/issues/19
|
)
|
||||||
Recordings.add_index("start_time", "end_time")
|
migrator.sql(
|
||||||
Recordings.create_table()
|
'CREATE UNIQUE INDEX IF NOT EXISTS "recordings_path" ON "recordings" ("path")'
|
||||||
|
)
|
||||||
migrator.python(add_index)
|
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):
|
def rollback(migrator, database, fake=False, **kwargs):
|
||||||
migrator.remove_model(Recordings)
|
pass
|
||||||
|
|||||||
47
migrations/006_add_motion_active_objects.py
Normal file
47
migrations/006_add_motion_active_objects.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""Peewee migrations -- 004_add_bbox_region_area.py.
|
||||||
|
|
||||||
|
Some examples (model - class or model name)::
|
||||||
|
|
||||||
|
> Model = migrator.orm['model_name'] # Return model in current state by name
|
||||||
|
|
||||||
|
> migrator.sql(sql) # Run custom SQL
|
||||||
|
> migrator.python(func, *args, **kwargs) # Run python code
|
||||||
|
> migrator.create_model(Model) # Create a model (could be used as decorator)
|
||||||
|
> migrator.remove_model(model, cascade=True) # Remove a model
|
||||||
|
> migrator.add_fields(model, **fields) # Add fields to a model
|
||||||
|
> migrator.change_fields(model, **fields) # Change fields
|
||||||
|
> migrator.remove_fields(model, *field_names, cascade=True)
|
||||||
|
> migrator.rename_field(model, old_field_name, new_field_name)
|
||||||
|
> migrator.rename_table(model, new_table_name)
|
||||||
|
> migrator.add_index(model, *col_names, unique=False)
|
||||||
|
> migrator.drop_index(model, *col_names)
|
||||||
|
> migrator.add_not_null(model, *field_names)
|
||||||
|
> migrator.drop_not_null(model, *field_names)
|
||||||
|
> migrator.add_default(model, field_name, default)
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import peewee as pw
|
||||||
|
from playhouse.sqlite_ext import *
|
||||||
|
from decimal import ROUND_HALF_EVEN
|
||||||
|
from frigate.models import Recordings
|
||||||
|
|
||||||
|
try:
|
||||||
|
import playhouse.postgres_ext as pw_pext
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
SQL = pw.SQL
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(migrator, database, fake=False, **kwargs):
|
||||||
|
migrator.add_fields(
|
||||||
|
Recordings,
|
||||||
|
objects=pw.IntegerField(null=True),
|
||||||
|
motion=pw.IntegerField(null=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def rollback(migrator, database, fake=False, **kwargs):
|
||||||
|
migrator.remove_fields(Recordings, ["objects", "motion"])
|
||||||
@@ -162,9 +162,12 @@ class ProcessClip:
|
|||||||
)
|
)
|
||||||
total_regions += len(regions)
|
total_regions += len(regions)
|
||||||
total_motion_boxes += len(motion_boxes)
|
total_motion_boxes += len(motion_boxes)
|
||||||
|
top_score = 0
|
||||||
for id, obj in self.camera_state.tracked_objects.items():
|
for id, obj in self.camera_state.tracked_objects.items():
|
||||||
if not obj.false_positive:
|
if not obj.false_positive:
|
||||||
object_ids.add(id)
|
object_ids.add(id)
|
||||||
|
if obj.top_score > top_score:
|
||||||
|
top_score = obj.top_score
|
||||||
|
|
||||||
total_frames += 1
|
total_frames += 1
|
||||||
|
|
||||||
@@ -175,6 +178,7 @@ class ProcessClip:
|
|||||||
"total_motion_boxes": total_motion_boxes,
|
"total_motion_boxes": total_motion_boxes,
|
||||||
"true_positive_objects": len(object_ids),
|
"true_positive_objects": len(object_ids),
|
||||||
"total_frames": total_frames,
|
"total_frames": total_frames,
|
||||||
|
"top_score": top_score,
|
||||||
}
|
}
|
||||||
|
|
||||||
def save_debug_frame(self, debug_path, frame_time, tracked_objects):
|
def save_debug_frame(self, debug_path, frame_time, tracked_objects):
|
||||||
@@ -277,6 +281,7 @@ def process(path, label, output, debug_path):
|
|||||||
|
|
||||||
frigate_config = FrigateConfig(**json_config)
|
frigate_config = FrigateConfig(**json_config)
|
||||||
runtime_config = frigate_config.runtime_config
|
runtime_config = frigate_config.runtime_config
|
||||||
|
runtime_config.cameras["camera"].create_ffmpeg_cmds()
|
||||||
|
|
||||||
process_clip = ProcessClip(c, frame_shape, runtime_config)
|
process_clip = ProcessClip(c, frame_shape, runtime_config)
|
||||||
process_clip.load_frames()
|
process_clip.load_frames()
|
||||||
|
|||||||
329
web/src/components/Calender.jsx
Normal file
329
web/src/components/Calender.jsx
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useEffect, useState, useCallback, useMemo, useRef } from 'preact/hooks';
|
||||||
|
import ArrowRight from '../icons/ArrowRight';
|
||||||
|
import ArrowRightDouble from '../icons/ArrowRightDouble';
|
||||||
|
|
||||||
|
const todayTimestamp = new Date().setHours(0, 0, 0, 0).valueOf();
|
||||||
|
|
||||||
|
const Calender = ({ onChange, calenderRef, close }) => {
|
||||||
|
const keyRef = useRef([]);
|
||||||
|
|
||||||
|
const date = new Date();
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = date.getMonth();
|
||||||
|
|
||||||
|
const daysMap = useMemo(() => ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'], []);
|
||||||
|
const monthMap = useMemo(
|
||||||
|
() => [
|
||||||
|
'January',
|
||||||
|
'February',
|
||||||
|
'March',
|
||||||
|
'April',
|
||||||
|
'May',
|
||||||
|
'June',
|
||||||
|
'July',
|
||||||
|
'August',
|
||||||
|
'September',
|
||||||
|
'October',
|
||||||
|
'November',
|
||||||
|
'December',
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [state, setState] = useState({
|
||||||
|
getMonthDetails: [],
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
selectedDay: null,
|
||||||
|
timeRange: { before: null, after: null },
|
||||||
|
monthDetails: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getNumberOfDays = useCallback((year, month) => {
|
||||||
|
return 40 - new Date(year, month, 40).getDate();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getDayDetails = useCallback(
|
||||||
|
(args) => {
|
||||||
|
const date = args.index - args.firstDay;
|
||||||
|
const day = args.index % 7;
|
||||||
|
let prevMonth = args.month - 1;
|
||||||
|
let prevYear = args.year;
|
||||||
|
if (prevMonth < 0) {
|
||||||
|
prevMonth = 11;
|
||||||
|
prevYear--;
|
||||||
|
}
|
||||||
|
const prevMonthNumberOfDays = getNumberOfDays(prevYear, prevMonth);
|
||||||
|
const _date = (date < 0 ? prevMonthNumberOfDays + date : date % args.numberOfDays) + 1;
|
||||||
|
const month = date < 0 ? -1 : date >= args.numberOfDays ? 1 : 0;
|
||||||
|
const timestamp = new Date(args.year, args.month, _date).getTime();
|
||||||
|
return {
|
||||||
|
date: _date,
|
||||||
|
day,
|
||||||
|
month,
|
||||||
|
timestamp,
|
||||||
|
dayString: daysMap[day],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[getNumberOfDays, daysMap]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getMonthDetails = useCallback(
|
||||||
|
(year, month) => {
|
||||||
|
const firstDay = new Date(year, month).getDay();
|
||||||
|
const numberOfDays = getNumberOfDays(year, month);
|
||||||
|
const monthArray = [];
|
||||||
|
const rows = 6;
|
||||||
|
let currentDay = null;
|
||||||
|
let index = 0;
|
||||||
|
const cols = 7;
|
||||||
|
|
||||||
|
for (let row = 0; row < rows; row++) {
|
||||||
|
for (let col = 0; col < cols; col++) {
|
||||||
|
currentDay = getDayDetails({
|
||||||
|
index,
|
||||||
|
numberOfDays,
|
||||||
|
firstDay,
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
});
|
||||||
|
monthArray.push(currentDay);
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return monthArray;
|
||||||
|
},
|
||||||
|
[getNumberOfDays, getDayDetails]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setState((prev) => ({ ...prev, selectedDay: todayTimestamp, monthDetails: getMonthDetails(year, month) }));
|
||||||
|
}, [year, month, getMonthDetails]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// add refs for keyboard navigation
|
||||||
|
if (state.monthDetails) {
|
||||||
|
keyRef.current = keyRef.current.slice(0, state.monthDetails.length);
|
||||||
|
}
|
||||||
|
// set today date in focus for keyboard navigation
|
||||||
|
const todayDate = new Date(todayTimestamp).getDate();
|
||||||
|
keyRef.current.find((t) => t.tabIndex === todayDate)?.focus();
|
||||||
|
}, [state.monthDetails]);
|
||||||
|
|
||||||
|
const isCurrentDay = (day) => day.timestamp === todayTimestamp;
|
||||||
|
|
||||||
|
const isSelectedRange = useCallback(
|
||||||
|
(day) => {
|
||||||
|
if (!state.timeRange.after || !state.timeRange.before) return;
|
||||||
|
|
||||||
|
return day.timestamp < state.timeRange.before && day.timestamp >= state.timeRange.after;
|
||||||
|
},
|
||||||
|
[state.timeRange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isFirstDayInRange = useCallback(
|
||||||
|
(day) => {
|
||||||
|
if (isCurrentDay(day)) return;
|
||||||
|
return state.timeRange.after === day.timestamp;
|
||||||
|
},
|
||||||
|
[state.timeRange.after]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isLastDayInRange = useCallback(
|
||||||
|
(day) => {
|
||||||
|
return state.timeRange.before === new Date(day.timestamp).setHours(24, 0, 0, 0);
|
||||||
|
},
|
||||||
|
[state.timeRange.before]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getMonthStr = useCallback(
|
||||||
|
(month) => {
|
||||||
|
return monthMap[Math.max(Math.min(11, month), 0)] || 'Month';
|
||||||
|
},
|
||||||
|
[monthMap]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onDateClick = (day) => {
|
||||||
|
const { before, after } = state.timeRange;
|
||||||
|
let timeRange = { before: null, after: null };
|
||||||
|
|
||||||
|
// user has selected a date < after, reset values
|
||||||
|
if (after === null || day.timestamp < after) {
|
||||||
|
timeRange = { before: new Date(day.timestamp).setHours(24, 0, 0, 0), after: day.timestamp };
|
||||||
|
}
|
||||||
|
|
||||||
|
// user has selected a date > after
|
||||||
|
if (after !== null && before !== new Date(day.timestamp).setHours(24, 0, 0, 0) && day.timestamp > after) {
|
||||||
|
timeRange = {
|
||||||
|
after,
|
||||||
|
before:
|
||||||
|
day.timestamp >= todayTimestamp
|
||||||
|
? new Date(todayTimestamp).setHours(24, 0, 0, 0)
|
||||||
|
: new Date(day.timestamp).setHours(24, 0, 0, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset values
|
||||||
|
if (before === new Date(day.timestamp).setHours(24, 0, 0, 0)) {
|
||||||
|
timeRange = { before: null, after: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
timeRange,
|
||||||
|
selectedDay: day.timestamp,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (onChange) {
|
||||||
|
onChange(timeRange.after ? { before: timeRange.before / 1000, after: timeRange.after / 1000 } : ['all']);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setYear = useCallback(
|
||||||
|
(offset) => {
|
||||||
|
const year = state.year + offset;
|
||||||
|
const month = state.month;
|
||||||
|
setState((prev) => {
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
year,
|
||||||
|
monthDetails: getMonthDetails(year, month),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[state.year, state.month, getMonthDetails]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setMonth = (offset) => {
|
||||||
|
let year = state.year;
|
||||||
|
let month = state.month + offset;
|
||||||
|
if (month === -1) {
|
||||||
|
month = 11;
|
||||||
|
year--;
|
||||||
|
} else if (month === 12) {
|
||||||
|
month = 0;
|
||||||
|
year++;
|
||||||
|
}
|
||||||
|
setState((prev) => {
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
monthDetails: getMonthDetails(year, month),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeydown = (e, day, index) => {
|
||||||
|
if ((keyRef.current && e.key === 'Enter') || e.keyCode === 32) {
|
||||||
|
e.preventDefault();
|
||||||
|
day.month === 0 && onDateClick(day);
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowLeft') {
|
||||||
|
index > 0 && keyRef.current[index - 1].focus();
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowRight') {
|
||||||
|
index < 41 && keyRef.current[index + 1].focus();
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
index > 6 && keyRef.current[index - 7].focus();
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
index < 36 && keyRef.current[index + 7].focus();
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCalendar = () => {
|
||||||
|
const days =
|
||||||
|
state.monthDetails &&
|
||||||
|
state.monthDetails.map((day, idx) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={() => onDateClick(day)}
|
||||||
|
onkeydown={(e) => handleKeydown(e, day, idx)}
|
||||||
|
ref={(ref) => (keyRef.current[idx] = ref)}
|
||||||
|
tabIndex={day.month === 0 ? day.date : null}
|
||||||
|
className={`h-12 w-12 float-left flex flex-shrink justify-center items-center cursor-pointer ${
|
||||||
|
day.month !== 0 ? ' opacity-50 bg-gray-700 dark:bg-gray-700 pointer-events-none' : ''
|
||||||
|
}
|
||||||
|
${isFirstDayInRange(day) ? ' rounded-l-xl ' : ''}
|
||||||
|
${isSelectedRange(day) ? ' bg-blue-600 dark:hover:bg-blue-600' : ''}
|
||||||
|
${isLastDayInRange(day) ? ' rounded-r-xl ' : ''}
|
||||||
|
${isCurrentDay(day) && !isLastDayInRange(day) ? 'rounded-full bg-gray-100 dark:hover:bg-gray-100 ' : ''}`}
|
||||||
|
key={idx}
|
||||||
|
>
|
||||||
|
<div className="font-light">
|
||||||
|
<span className="text-gray-400">{day.date}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="w-full flex justify-start flex-shrink">
|
||||||
|
{['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'].map((d, i) => (
|
||||||
|
<div key={i} className="w-12 text-xs font-light text-center">
|
||||||
|
{d}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-56">{days}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="select-none w-96 flex flex-shrink" ref={calenderRef}>
|
||||||
|
<div className="py-4 px-6">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="w-1/6 relative flex justify-around">
|
||||||
|
<div
|
||||||
|
tabIndex={100}
|
||||||
|
className="flex justify-center items-center cursor-pointer absolute -mt-4 text-center rounded-full w-10 h-10 bg-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800"
|
||||||
|
onClick={() => setYear(-1)}
|
||||||
|
>
|
||||||
|
<ArrowRightDouble className="h-2/6 transform rotate-180 " />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-1/6 relative flex justify-around ">
|
||||||
|
<div
|
||||||
|
tabIndex={101}
|
||||||
|
className="flex justify-center items-center cursor-pointer absolute -mt-4 text-center rounded-full w-10 h-10 bg-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800"
|
||||||
|
onClick={() => setMonth(-1)}
|
||||||
|
>
|
||||||
|
<ArrowRight className="h-2/6 transform rotate-180 red" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-1/3">
|
||||||
|
<div className="text-3xl text-center text-gray-200 font-extralight">{state.year}</div>
|
||||||
|
<div className="text-center text-gray-400 font-extralight">{getMonthStr(state.month)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-1/6 relative flex justify-around ">
|
||||||
|
<div
|
||||||
|
tabIndex={102}
|
||||||
|
className="flex justify-center items-center cursor-pointer absolute -mt-4 text-center rounded-full w-10 h-10 bg-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800"
|
||||||
|
onClick={() => setMonth(1)}
|
||||||
|
>
|
||||||
|
<ArrowRight className="h-2/6" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-1/6 relative flex justify-around " tabIndex={104} onClick={() => setYear(1)}>
|
||||||
|
<div className="flex justify-center items-center cursor-pointer absolute -mt-4 text-center rounded-full w-10 h-10 bg-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800">
|
||||||
|
<ArrowRightDouble className="h-2/6" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">{renderCalendar()}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Calender;
|
||||||
162
web/src/components/DatePicker.jsx
Normal file
162
web/src/components/DatePicker.jsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useCallback, useEffect, useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
export const DateFilterOptions = [
|
||||||
|
{
|
||||||
|
label: 'All',
|
||||||
|
value: ['all'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Today',
|
||||||
|
value: {
|
||||||
|
//Before
|
||||||
|
before: new Date().setHours(24, 0, 0, 0) / 1000,
|
||||||
|
//After
|
||||||
|
after: new Date().setHours(0, 0, 0, 0) / 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Yesterday',
|
||||||
|
value: {
|
||||||
|
//Before
|
||||||
|
before: new Date(new Date().setDate(new Date().getDate() - 1)).setHours(24, 0, 0, 0) / 1000,
|
||||||
|
//After
|
||||||
|
after: new Date(new Date().setDate(new Date().getDate() - 1)).setHours(0, 0, 0, 0) / 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Last 7 Days',
|
||||||
|
value: {
|
||||||
|
//Before
|
||||||
|
before: new Date().setHours(24, 0, 0, 0) / 1000,
|
||||||
|
//After
|
||||||
|
after: new Date(new Date().setDate(new Date().getDate() - 7)).setHours(0, 0, 0, 0) / 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'This Month',
|
||||||
|
value: {
|
||||||
|
//Before
|
||||||
|
before: new Date().setHours(24, 0, 0, 0) / 1000,
|
||||||
|
//After
|
||||||
|
after: new Date(new Date().getFullYear(), new Date().getMonth(), 1).getTime() / 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Last Month',
|
||||||
|
value: {
|
||||||
|
//Before
|
||||||
|
before: new Date(new Date().getFullYear(), new Date().getMonth(), 1).getTime() / 1000,
|
||||||
|
//After
|
||||||
|
after: new Date(new Date().getFullYear(), new Date().getMonth() - 1, 1).getTime() / 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Custom Range',
|
||||||
|
value: 'custom_range',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function DatePicker({
|
||||||
|
helpText,
|
||||||
|
keyboardType = 'text',
|
||||||
|
inputRef,
|
||||||
|
label,
|
||||||
|
leadingIcon: LeadingIcon,
|
||||||
|
onBlur,
|
||||||
|
onChangeText,
|
||||||
|
onFocus,
|
||||||
|
readonly,
|
||||||
|
trailingIcon: TrailingIcon,
|
||||||
|
value: propValue = '',
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
const [isFocused, setFocused] = useState(false);
|
||||||
|
const [value, setValue] = useState(propValue);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (propValue !== value) {
|
||||||
|
setValue(propValue);
|
||||||
|
}
|
||||||
|
}, [propValue, setValue, value]);
|
||||||
|
|
||||||
|
const handleFocus = useCallback(
|
||||||
|
(event) => {
|
||||||
|
setFocused(true);
|
||||||
|
onFocus && onFocus(event);
|
||||||
|
},
|
||||||
|
[onFocus]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBlur = useCallback(
|
||||||
|
(event) => {
|
||||||
|
setFocused(false);
|
||||||
|
onBlur && onBlur(event);
|
||||||
|
},
|
||||||
|
[onBlur]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(event) => {
|
||||||
|
const { value } = event.target;
|
||||||
|
setValue(value);
|
||||||
|
onChangeText && onChangeText(value);
|
||||||
|
},
|
||||||
|
[onChangeText, setValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onClick = (e) => {
|
||||||
|
props.onclick(e);
|
||||||
|
};
|
||||||
|
const labelMoved = isFocused || value !== '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{props.children}
|
||||||
|
<div
|
||||||
|
className={`bg-gray-100 dark:bg-gray-700 rounded rounded-b-none border-gray-400 border-b p-1 pl-4 pr-3 ${
|
||||||
|
isFocused ? 'border-blue-500 dark:border-blue-500' : ''
|
||||||
|
}`}
|
||||||
|
ref={inputRef}
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
className="flex space-x-2 items-center"
|
||||||
|
data-testid={`label-${label.toLowerCase().replace(/[^\w]+/g, '_')}`}
|
||||||
|
>
|
||||||
|
{LeadingIcon ? (
|
||||||
|
<div className="w-10 h-full">
|
||||||
|
<LeadingIcon />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="relative w-full">
|
||||||
|
<input
|
||||||
|
className="h-6 mt-6 w-full bg-transparent focus:outline-none focus:ring-0"
|
||||||
|
type={keyboardType}
|
||||||
|
readOnly
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onInput={handleChange}
|
||||||
|
tabIndex="0"
|
||||||
|
onClick={onClick}
|
||||||
|
value={propValue}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`absolute top-3 transition transform text-gray-600 dark:text-gray-400 ${
|
||||||
|
labelMoved ? 'text-xs -translate-y-2' : ''
|
||||||
|
} ${isFocused ? 'text-blue-500 dark:text-blue-500' : ''}`}
|
||||||
|
>
|
||||||
|
<p>{label}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{TrailingIcon ? (
|
||||||
|
<div className="w-10 h-10">
|
||||||
|
<TrailingIcon />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{helpText ? <div className="text-xs pl-3 pt-1">{helpText}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ export default function RelativeModal({
|
|||||||
|
|
||||||
const handleKeydown = useCallback(
|
const handleKeydown = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
const focusable = ref.current.querySelectorAll('[tabindex]');
|
const focusable = ref.current && ref.current.querySelectorAll('[tabindex]');
|
||||||
if (event.key === 'Tab' && focusable.length) {
|
if (event.key === 'Tab' && focusable.length) {
|
||||||
if (event.shiftKey && document.activeElement === focusable[0]) {
|
if (event.shiftKey && document.activeElement === focusable[0]) {
|
||||||
focusable[focusable.length - 1].focus();
|
focusable[focusable.length - 1].focus();
|
||||||
@@ -69,14 +69,15 @@ export default function RelativeModal({
|
|||||||
let newTop = top;
|
let newTop = top;
|
||||||
let newLeft = left;
|
let newLeft = left;
|
||||||
|
|
||||||
// too far right
|
|
||||||
if (newLeft + width + WINDOW_PADDING >= windowWidth - WINDOW_PADDING) {
|
|
||||||
newLeft = windowWidth - width - WINDOW_PADDING;
|
|
||||||
}
|
|
||||||
// too far left
|
// too far left
|
||||||
else if (left < WINDOW_PADDING) {
|
if (left < WINDOW_PADDING) {
|
||||||
newLeft = 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
|
// too close to bottom
|
||||||
if (top + menuHeight > windowHeight - WINDOW_PADDING + window.scrollY) {
|
if (top + menuHeight > windowHeight - WINDOW_PADDING + window.scrollY) {
|
||||||
newTop = WINDOW_PADDING;
|
newTop = WINDOW_PADDING;
|
||||||
|
|||||||
@@ -3,74 +3,27 @@ import ArrowDropdown from '../icons/ArrowDropdown';
|
|||||||
import ArrowDropup from '../icons/ArrowDropup';
|
import ArrowDropup from '../icons/ArrowDropup';
|
||||||
import Menu, { MenuItem } from './Menu';
|
import Menu, { MenuItem } from './Menu';
|
||||||
import TextField from './TextField';
|
import TextField from './TextField';
|
||||||
|
import DatePicker from './DatePicker';
|
||||||
|
import Calender from './Calender';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
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(
|
const options = useMemo(
|
||||||
() =>
|
() =>
|
||||||
typeof inputOptions[0] === 'string' ? inputOptions.map((opt) => ({ value: opt, label: opt })) : inputOptions,
|
typeof inputOptions[0] === 'string' ? inputOptions.map((opt) => ({ value: opt, label: opt })) : inputOptions,
|
||||||
[inputOptions]
|
[inputOptions]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [showMenu, setShowMenu] = useState(false);
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
const [selected, setSelected] = useState(
|
const [selected, setSelected] = useState();
|
||||||
Math.max(
|
const [datePickerValue, setDatePickerValue] = useState();
|
||||||
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]);
|
|
||||||
|
|
||||||
// Reset the state if the prop value changes
|
// Reset the state if the prop value changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -85,25 +38,219 @@ export default function Select({ label, onChange, options: inputOptions = [], se
|
|||||||
// DO NOT include `selected`
|
// DO NOT include `selected`
|
||||||
}, [options, propSelected]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [options, propSelected]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
return (
|
useEffect(() => {
|
||||||
<Fragment>
|
if (type === 'datepicker') {
|
||||||
<TextField
|
if ('after' && 'before' in propSelected) {
|
||||||
inputRef={ref}
|
if (!propSelected.before || !propSelected.after) return setDatePickerValue('all');
|
||||||
label={label}
|
|
||||||
onchange={onChange}
|
for (let i = 0; i < inputOptions.length; i++) {
|
||||||
onclick={handleClick}
|
if (
|
||||||
onkeydown={handleKeydown}
|
inputOptions[i].value &&
|
||||||
readonly
|
Object.entries(inputOptions[i].value).sort().toString() === Object.entries(propSelected).sort().toString()
|
||||||
trailingIcon={showMenu ? ArrowDropup : ArrowDropdown}
|
) {
|
||||||
value={options[selected]?.label}
|
setDatePickerValue(inputOptions[i]?.label);
|
||||||
/>
|
break;
|
||||||
{showMenu ? (
|
} else {
|
||||||
<Menu className="rounded-t-none" onDismiss={handleDismiss} relativeTo={ref} widthRelative>
|
setDatePickerValue(
|
||||||
{options.map(({ value, label }, i) => (
|
`${new Date(propSelected.after * 1000).toLocaleDateString()} -> ${new Date(
|
||||||
<MenuItem key={value} label={label} focus={focused === i} onSelect={handleSelect} value={value} />
|
propSelected.before * 1000 - 1
|
||||||
))}
|
).toLocaleDateString()}`
|
||||||
</Menu>
|
);
|
||||||
) : null}
|
}
|
||||||
</Fragment>
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (type === 'dropdown') {
|
||||||
|
setSelected(
|
||||||
|
Math.max(
|
||||||
|
options.findIndex(({ value }) => Object.values(propSelected).includes(value)),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [type, options, inputOptions, propSelected, setSelected]);
|
||||||
|
|
||||||
|
const [focused, setFocused] = useState(null);
|
||||||
|
const [showCalender, setShowCalender] = useState(false);
|
||||||
|
const calenderRef = useRef(null);
|
||||||
|
const ref = useRef(null);
|
||||||
|
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
(value) => {
|
||||||
|
setSelected(options.findIndex(({ value }) => Object.values(propSelected).includes(value)));
|
||||||
|
setShowMenu(false);
|
||||||
|
|
||||||
|
//show calender date range picker
|
||||||
|
if (value === 'custom_range') return setShowCalender(true);
|
||||||
|
onChange && onChange(value);
|
||||||
|
},
|
||||||
|
[onChange, options, propSelected, setSelected]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleDateRange = useCallback(
|
||||||
|
(range) => {
|
||||||
|
onChange && onChange(range);
|
||||||
|
setShowMenu(false);
|
||||||
|
},
|
||||||
|
[onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
setShowMenu(true);
|
||||||
|
}, [setShowMenu]);
|
||||||
|
|
||||||
|
const handleKeydownDatePicker = useCallback(
|
||||||
|
(event) => {
|
||||||
|
switch (event.key) {
|
||||||
|
case 'Enter': {
|
||||||
|
if (!showMenu) {
|
||||||
|
setShowMenu(true);
|
||||||
|
setFocused(selected);
|
||||||
|
} else {
|
||||||
|
setSelected(focused);
|
||||||
|
if (options[focused].value === 'custom_range') {
|
||||||
|
setShowMenu(false);
|
||||||
|
return setShowCalender(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange && onChange(options[focused].value);
|
||||||
|
setShowMenu(false);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ArrowDown': {
|
||||||
|
event.preventDefault();
|
||||||
|
const newIndex = focused + 1;
|
||||||
|
newIndex < options.length && setFocused(newIndex);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ArrowUp': {
|
||||||
|
event.preventDefault();
|
||||||
|
const newIndex = focused - 1;
|
||||||
|
newIndex > -1 && setFocused(newIndex);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// no default
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onChange, options, showMenu, setShowMenu, setFocused, focused, selected]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeydown = useCallback(
|
||||||
|
(event) => {
|
||||||
|
switch (event.key) {
|
||||||
|
case 'Enter': {
|
||||||
|
if (!showMenu) {
|
||||||
|
setShowMenu(true);
|
||||||
|
setFocused(selected);
|
||||||
|
} else {
|
||||||
|
setSelected(focused);
|
||||||
|
onChange && onChange({ [paramName]: options[focused].value });
|
||||||
|
setShowMenu(false);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ArrowDown': {
|
||||||
|
event.preventDefault();
|
||||||
|
const newIndex = focused + 1;
|
||||||
|
newIndex < options.length && setFocused(newIndex);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ArrowUp': {
|
||||||
|
event.preventDefault();
|
||||||
|
const newIndex = focused - 1;
|
||||||
|
newIndex > -1 && setFocused(newIndex);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// no default
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onChange, options, showMenu, setShowMenu, setFocused, focused, selected, paramName]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDismiss = useCallback(() => {
|
||||||
|
setShowMenu(false);
|
||||||
|
}, [setShowMenu]);
|
||||||
|
|
||||||
|
const findDOMNodes = (component) => {
|
||||||
|
return (component && (component.base || (component.nodeType === 1 && component))) || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const addBackDrop = (e) => {
|
||||||
|
if (showCalender && !findDOMNodes(calenderRef.current).contains(e.target)) {
|
||||||
|
setShowCalender(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('click', addBackDrop);
|
||||||
|
|
||||||
|
return function cleanup() {
|
||||||
|
window.removeEventListener('click', addBackDrop);
|
||||||
|
};
|
||||||
|
}, [showCalender]);
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'datepicker':
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<DatePicker
|
||||||
|
inputRef={ref}
|
||||||
|
label={label}
|
||||||
|
onchange={onChange}
|
||||||
|
onclick={handleClick}
|
||||||
|
onkeydown={handleKeydownDatePicker}
|
||||||
|
trailingIcon={showMenu ? ArrowDropup : ArrowDropdown}
|
||||||
|
value={datePickerValue}
|
||||||
|
/>
|
||||||
|
{showCalender && (
|
||||||
|
<Menu className="rounded-t-none" onDismiss={handleDismiss} relativeTo={ref}>
|
||||||
|
<Calender onChange={handleDateRange} calenderRef={calenderRef} close={() => setShowCalender(false)} />
|
||||||
|
</Menu>
|
||||||
|
)}
|
||||||
|
{showMenu ? (
|
||||||
|
<Menu className="rounded-t-none" onDismiss={handleDismiss} relativeTo={ref} widthRelative>
|
||||||
|
{options.map(({ value, label }, i) => (
|
||||||
|
<MenuItem key={value} label={label} focus={focused === i} onSelect={handleSelect} value={value} />
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
) : null}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
|
||||||
|
// case 'dropdown':
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<TextField
|
||||||
|
inputRef={ref}
|
||||||
|
label={label}
|
||||||
|
onchange={onChange}
|
||||||
|
onclick={handleClick}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
readonly
|
||||||
|
trailingIcon={showMenu ? ArrowDropup : ArrowDropdown}
|
||||||
|
value={options[selected]?.label}
|
||||||
|
/>
|
||||||
|
{showMenu ? (
|
||||||
|
<Menu className="rounded-t-none" onDismiss={handleDismiss} relativeTo={ref} widthRelative>
|
||||||
|
{options.map(({ value, label }, i) => (
|
||||||
|
<MenuItem
|
||||||
|
key={value}
|
||||||
|
label={label}
|
||||||
|
focus={focused === i}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
value={{ [paramName]: value }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
) : null}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,21 +5,40 @@ import { fireEvent, render, screen } from '@testing-library/preact';
|
|||||||
describe('Select', () => {
|
describe('Select', () => {
|
||||||
test('on focus, shows a menu', async () => {
|
test('on focus, shows a menu', async () => {
|
||||||
const handleChange = jest.fn();
|
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();
|
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole('textbox'));
|
fireEvent.click(screen.getByRole('textbox'));
|
||||||
expect(screen.queryByRole('listbox')).toBeInTheDocument();
|
expect(screen.queryByRole('listbox')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('option', { name: 'all' })).toBeInTheDocument();
|
||||||
expect(screen.queryByRole('option', { name: 'tacos' })).toBeInTheDocument();
|
expect(screen.queryByRole('option', { name: 'tacos' })).toBeInTheDocument();
|
||||||
expect(screen.queryByRole('option', { name: 'burritos' })).toBeInTheDocument();
|
expect(screen.queryByRole('option', { name: 'burritos' })).toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.click(screen.queryByRole('option', { name: 'burritos' }));
|
fireEvent.click(screen.queryByRole('option', { name: 'tacos' }));
|
||||||
expect(handleChange).toHaveBeenCalledWith('burritos', 'burritos');
|
expect(handleChange).toHaveBeenCalledWith({ dinner: 'tacos' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('allows keyboard navigation', async () => {
|
test('allows keyboard navigation', async () => {
|
||||||
const handleChange = jest.fn();
|
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();
|
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||||
const input = screen.getByRole('textbox');
|
const input = screen.getByRole('textbox');
|
||||||
@@ -29,6 +48,6 @@ describe('Select', () => {
|
|||||||
|
|
||||||
fireEvent.keyDown(input, { key: 'ArrowDown', code: 'ArrowDown' });
|
fireEvent.keyDown(input, { key: 'ArrowDown', code: 'ArrowDown' });
|
||||||
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
||||||
expect(handleChange).toHaveBeenCalledWith('burritos', 'burritos');
|
expect(handleChange).toHaveBeenCalledWith({ dinner: 'burritos' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ export const useSearchString = (limit, searchParams) => {
|
|||||||
const removeDefaultSearchKeys = useCallback((searchParams) => {
|
const removeDefaultSearchKeys = useCallback((searchParams) => {
|
||||||
searchParams.delete('limit');
|
searchParams.delete('limit');
|
||||||
searchParams.delete('include_thumbnails');
|
searchParams.delete('include_thumbnails');
|
||||||
searchParams.delete('before');
|
// removed deletion of "before" as its used by DatePicker
|
||||||
|
// searchParams.delete('before');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { searchString, setSearchString, removeDefaultSearchKeys };
|
return { searchString, setSearchString, removeDefaultSearchKeys };
|
||||||
|
|||||||
18
web/src/icons/ArrowLeft.jsx
Normal file
18
web/src/icons/ArrowLeft.jsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
|
export function ArrowLeft({ className = '' }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={`fill-current ${className}`}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path d="M12 0c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm-1.218 19l-1.782-1.75 5.25-5.25-5.25-5.25 1.782-1.75 6.968 7-6.968 7z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(ArrowLeft);
|
||||||
12
web/src/icons/ArrowRight.jsx
Normal file
12
web/src/icons/ArrowRight.jsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
|
export function ArrowRight({ className = '' }) {
|
||||||
|
return (
|
||||||
|
<svg className={`fill-current ${className}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path d="M5 3l3.057-3 11.943 12-11.943 12-3.057-3 9-9z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(ArrowRight);
|
||||||
12
web/src/icons/ArrowRightDouble.jsx
Normal file
12
web/src/icons/ArrowRightDouble.jsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
|
export function ArrowRightDouble({ className = '' }) {
|
||||||
|
return (
|
||||||
|
<svg className={`fill-current ${className}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path d="M0 3.795l2.995-2.98 11.132 11.185-11.132 11.186-2.995-2.981 8.167-8.205-8.167-8.205zm18.04 8.205l-8.167 8.205 2.995 2.98 11.132-11.185-11.132-11.186-2.995 2.98 8.167 8.206z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(ArrowRightDouble);
|
||||||
@@ -199,7 +199,7 @@ export default function Event({ eventId, close, scrollRef }) {
|
|||||||
<img
|
<img
|
||||||
src={
|
src={
|
||||||
data.has_snapshot
|
data.has_snapshot
|
||||||
? `${apiHost}/clips/${data.camera}-${eventId}.jpg`
|
? `${apiHost}/api/events/${eventId}/snapshot.jpg`
|
||||||
: `data:image/jpeg;base64,${data.thumbnail}`
|
: `data:image/jpeg;base64,${data.thumbnail}`
|
||||||
}
|
}
|
||||||
alt={`${data.label} at ${(data.top_score * 100).toFixed(1)}% confidence`}
|
alt={`${data.label} at ${(data.top_score * 100).toFixed(1)}% confidence`}
|
||||||
|
|||||||
@@ -1,31 +1,26 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import Select from '../../../components/Select';
|
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(
|
const handleSelect = useCallback(
|
||||||
(key) => {
|
(key) => {
|
||||||
const newParams = new URLSearchParams(searchParams.toString());
|
const newParams = new URLSearchParams(searchParams.toString());
|
||||||
if (key !== 'all') {
|
Object.keys(key).map((entries) => {
|
||||||
newParams.set(paramName, key);
|
if (key[entries] !== 'all') {
|
||||||
} else {
|
newParams.set(entries, key[entries]);
|
||||||
newParams.delete(paramName);
|
} else {
|
||||||
}
|
paramName.map((p) => newParams.delete(p));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
onChange(newParams);
|
onChange(newParams);
|
||||||
},
|
},
|
||||||
[searchParams, paramName, onChange]
|
[searchParams, paramName, onChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectOptions = useMemo(() => ['all', ...options], [options]);
|
const obj = {};
|
||||||
|
paramName.map((name) => Object.assign(obj, { [name]: searchParams.get(name) }), [searchParams]);
|
||||||
return (
|
return <Select onChange={handleSelect} options={options} selected={obj} paramName={paramName} {...rest} />;
|
||||||
<Select
|
}
|
||||||
label={`${paramName.charAt(0).toUpperCase()}${paramName.substr(1)}`}
|
|
||||||
onChange={handleSelect}
|
|
||||||
options={selectOptions}
|
|
||||||
selected={searchParams.get(paramName) || 'all'}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
export default Filter;
|
export default Filter;
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ import { useCallback, useMemo } from 'preact/hooks';
|
|||||||
import Link from '../../../components/Link';
|
import Link from '../../../components/Link';
|
||||||
import { route } from 'preact-router';
|
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 href = useMemo(() => {
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
params.set(paramName, name);
|
params.set(paramName, name);
|
||||||
@@ -27,6 +33,6 @@ const Filterable = ({ onFilter, pathname, searchParams, paramName, name, removeD
|
|||||||
{name}
|
{name}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default Filterable;
|
export default Filterable;
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import Filter from './filter';
|
import Filter from './filter';
|
||||||
import { useConfig } from '../../../api';
|
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 Filters = ({ onChange, searchParams }) => {
|
||||||
|
const [viewFilters, setViewFilters] = useState(false);
|
||||||
const { data } = useConfig();
|
const { data } = useConfig();
|
||||||
|
|
||||||
const cameras = useMemo(() => Object.keys(data.cameras), [data]);
|
const cameras = useMemo(() => Object.keys(data.cameras), [data]);
|
||||||
|
|
||||||
const zones = useMemo(
|
const zones = useMemo(
|
||||||
@@ -27,12 +29,52 @@ const Filters = ({ onChange, searchParams }) => {
|
|||||||
}, data.objects?.track || [])
|
}, data.objects?.track || [])
|
||||||
.filter((value, i, self) => self.indexOf(value) === i);
|
.filter((value, i, self) => self.indexOf(value) === i);
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex space-x-4">
|
<div>
|
||||||
<Filter onChange={onChange} options={cameras} paramName="camera" searchParams={searchParams} />
|
<Button
|
||||||
<Filter onChange={onChange} options={zones} paramName="zone" searchParams={searchParams} />
|
onClick={() => setViewFilters(!viewFilters)}
|
||||||
<Filter onChange={onChange} options={labels} paramName="label" searchParams={searchParams} />
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user