Compare commits

..

19 Commits

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

* dev

* dev

* dev

* fix for version 0.10

* added rounded corners for date range

* lint

* Commented out some Select.test.

* improved date range selection

* improved functions with useCallback

* improved Select.test.jsx

* keyboard navigation

* keyboard navigation

* added dropdown menu icon

* Hide filters on xs, Button to show

* check if to far left before right

* Filter button text

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

* run tests in the Dockerfile during the build phase

* remove local tests

Co-authored-by: YS <ys@gm.com>
2022-01-14 07:31:25 -06:00
Yuriy Sannikov
9edf38347c safe refactoring (#2552)
Co-authored-by: YS <ys@gm.com>
2021-12-31 11:59:43 -06:00
TJ Horner
1569ce7cf6 Change JPEG mime type (#2543) 2021-12-30 17:03:29 -06:00
27 changed files with 1145 additions and 222 deletions

View File

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

View File

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

View File

@@ -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,6 +225,9 @@ motion:
record: record:
# Optional: Enable recording (default: shown below) # Optional: Enable recording (default: shown below)
enabled: False enabled: False
# Optional: Number of minutes to wait between cleanup runs (default: shown below)
# This can be used to reduce the frequency of deleting recording segments from disk if you want to minimize i/o
expire_interval: 60
# Optional: Retention settings for recording # Optional: Retention settings for recording
retain: retain:
# Optional: Number of days to retain recordings regardless of events (default: shown below) # Optional: Number of days to retain recordings regardless of events (default: shown below)
@@ -264,7 +268,7 @@ record:
# here, the segments will already be gone by the time this mode is applied. # 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 # 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. # never stored, so setting the mode to "all" here won't bring them back.
mode: active_objects mode: motion
# Optional: Per object retention days # Optional: Per object retention days
objects: objects:
person: 15 person: 15

View File

@@ -77,9 +77,6 @@ class FrigateApp:
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),

View File

@@ -13,8 +13,7 @@ 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, YAML_EXT 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__)
@@ -73,9 +72,7 @@ class RetainModeEnum(str, Enum):
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( mode: RetainModeEnum = Field(default=RetainModeEnum.motion, title="Retain mode.")
default=RetainModeEnum.active_objects, 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."
) )
@@ -104,6 +101,10 @@ class RecordRetainConfig(FrigateBaseModel):
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.")
expire_interval: int = Field(
default=60,
title="Number of minutes to wait between cleanup runs.",
)
# deprecated - to be removed in a future version # deprecated - to be removed in a future version
retain_days: Optional[float] = Field(title="Recording retention period in days.") retain_days: Optional[float] = Field(title="Recording retention period in days.")
retain: RecordRetainConfig = Field( retain: RecordRetainConfig = Field(
@@ -172,8 +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(
default=0,
title="Frame interval for checking stationary objects.", title="Frame interval for checking stationary objects.",
ge=1, ge=0,
) )
@@ -474,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,
@@ -538,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)
@@ -640,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):
@@ -762,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)
@@ -846,7 +845,8 @@ class FrigateConfig(FrigateBaseModel):
logger.warning( 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." 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

View File

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

View File

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

View File

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

View File

@@ -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__)
@@ -178,6 +178,7 @@ class TrackedObject:
"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"], "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": self.entered_zones.copy(), "entered_zones": self.entered_zones.copy(),
"has_clip": self.has_clip, "has_clip": self.has_clip,
@@ -264,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]]
@@ -731,6 +739,10 @@ 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 set(obj.entered_zones) & set(required_zones): if len(required_zones) > 0 and not set(obj.entered_zones) & set(required_zones):
@@ -751,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):
@@ -772,6 +788,10 @@ 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 set(obj.entered_zones) & set(required_zones): if len(required_zones) > 0 and not set(obj.entered_zones) & set(required_zones):

View File

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

View File

@@ -497,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(
@@ -548,7 +549,7 @@ class RecordingCleanup(threading.Thread):
# self.sync_recordings() # self.sync_recordings()
# Expire tmp clips every minute, recordings and clean directories every hour. # Expire tmp clips every minute, recordings and clean directories every hour.
for counter in itertools.cycle(range(60)): 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

View File

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

View File

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

View File

@@ -189,12 +189,12 @@ def draw_box_with_label(
) )
def calculate_region(frame_shape, xmin, ymin, xmax, ymax, multiplier=2): def calculate_region(frame_shape, xmin, ymin, xmax, ymax, model_size, multiplier=2):
# size is the longest edge and divisible by 4 # size 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

View File

@@ -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
@@ -469,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...")
@@ -512,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
@@ -529,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 = [
@@ -554,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(
@@ -569,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
@@ -596,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)
@@ -624,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():

View File

@@ -0,0 +1,329 @@
import { h } from 'preact';
import { useEffect, useState, useCallback, useMemo, useRef } from 'preact/hooks';
import ArrowRight from '../icons/ArrowRight';
import ArrowRightDouble from '../icons/ArrowRightDouble';
const todayTimestamp = new Date().setHours(0, 0, 0, 0).valueOf();
const Calender = ({ onChange, calenderRef, close }) => {
const keyRef = useRef([]);
const date = new Date();
const year = date.getFullYear();
const month = date.getMonth();
const daysMap = useMemo(() => ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'], []);
const monthMap = useMemo(
() => [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
],
[]
);
const [state, setState] = useState({
getMonthDetails: [],
year,
month,
selectedDay: null,
timeRange: { before: null, after: null },
monthDetails: null,
});
const getNumberOfDays = useCallback((year, month) => {
return 40 - new Date(year, month, 40).getDate();
}, []);
const getDayDetails = useCallback(
(args) => {
const date = args.index - args.firstDay;
const day = args.index % 7;
let prevMonth = args.month - 1;
let prevYear = args.year;
if (prevMonth < 0) {
prevMonth = 11;
prevYear--;
}
const prevMonthNumberOfDays = getNumberOfDays(prevYear, prevMonth);
const _date = (date < 0 ? prevMonthNumberOfDays + date : date % args.numberOfDays) + 1;
const month = date < 0 ? -1 : date >= args.numberOfDays ? 1 : 0;
const timestamp = new Date(args.year, args.month, _date).getTime();
return {
date: _date,
day,
month,
timestamp,
dayString: daysMap[day],
};
},
[getNumberOfDays, daysMap]
);
const getMonthDetails = useCallback(
(year, month) => {
const firstDay = new Date(year, month).getDay();
const numberOfDays = getNumberOfDays(year, month);
const monthArray = [];
const rows = 6;
let currentDay = null;
let index = 0;
const cols = 7;
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
currentDay = getDayDetails({
index,
numberOfDays,
firstDay,
year,
month,
});
monthArray.push(currentDay);
index++;
}
}
return monthArray;
},
[getNumberOfDays, getDayDetails]
);
useEffect(() => {
setState((prev) => ({ ...prev, selectedDay: todayTimestamp, monthDetails: getMonthDetails(year, month) }));
}, [year, month, getMonthDetails]);
useEffect(() => {
// add refs for keyboard navigation
if (state.monthDetails) {
keyRef.current = keyRef.current.slice(0, state.monthDetails.length);
}
// set today date in focus for keyboard navigation
const todayDate = new Date(todayTimestamp).getDate();
keyRef.current.find((t) => t.tabIndex === todayDate)?.focus();
}, [state.monthDetails]);
const isCurrentDay = (day) => day.timestamp === todayTimestamp;
const isSelectedRange = useCallback(
(day) => {
if (!state.timeRange.after || !state.timeRange.before) return;
return day.timestamp < state.timeRange.before && day.timestamp >= state.timeRange.after;
},
[state.timeRange]
);
const isFirstDayInRange = useCallback(
(day) => {
if (isCurrentDay(day)) return;
return state.timeRange.after === day.timestamp;
},
[state.timeRange.after]
);
const isLastDayInRange = useCallback(
(day) => {
return state.timeRange.before === new Date(day.timestamp).setHours(24, 0, 0, 0);
},
[state.timeRange.before]
);
const getMonthStr = useCallback(
(month) => {
return monthMap[Math.max(Math.min(11, month), 0)] || 'Month';
},
[monthMap]
);
const onDateClick = (day) => {
const { before, after } = state.timeRange;
let timeRange = { before: null, after: null };
// user has selected a date < after, reset values
if (after === null || day.timestamp < after) {
timeRange = { before: new Date(day.timestamp).setHours(24, 0, 0, 0), after: day.timestamp };
}
// user has selected a date > after
if (after !== null && before !== new Date(day.timestamp).setHours(24, 0, 0, 0) && day.timestamp > after) {
timeRange = {
after,
before:
day.timestamp >= todayTimestamp
? new Date(todayTimestamp).setHours(24, 0, 0, 0)
: new Date(day.timestamp).setHours(24, 0, 0, 0),
};
}
// reset values
if (before === new Date(day.timestamp).setHours(24, 0, 0, 0)) {
timeRange = { before: null, after: null };
}
setState((prev) => ({
...prev,
timeRange,
selectedDay: day.timestamp,
}));
if (onChange) {
onChange(timeRange.after ? { before: timeRange.before / 1000, after: timeRange.after / 1000 } : ['all']);
}
};
const setYear = useCallback(
(offset) => {
const year = state.year + offset;
const month = state.month;
setState((prev) => {
return {
...prev,
year,
monthDetails: getMonthDetails(year, month),
};
});
},
[state.year, state.month, getMonthDetails]
);
const setMonth = (offset) => {
let year = state.year;
let month = state.month + offset;
if (month === -1) {
month = 11;
year--;
} else if (month === 12) {
month = 0;
year++;
}
setState((prev) => {
return {
...prev,
year,
month,
monthDetails: getMonthDetails(year, month),
};
});
};
const handleKeydown = (e, day, index) => {
if ((keyRef.current && e.key === 'Enter') || e.keyCode === 32) {
e.preventDefault();
day.month === 0 && onDateClick(day);
}
if (e.key === 'ArrowLeft') {
index > 0 && keyRef.current[index - 1].focus();
}
if (e.key === 'ArrowRight') {
index < 41 && keyRef.current[index + 1].focus();
}
if (e.key === 'ArrowUp') {
e.preventDefault();
index > 6 && keyRef.current[index - 7].focus();
}
if (e.key === 'ArrowDown') {
e.preventDefault();
index < 36 && keyRef.current[index + 7].focus();
}
if (e.key === 'Escape') {
close();
}
};
const renderCalendar = () => {
const days =
state.monthDetails &&
state.monthDetails.map((day, idx) => {
return (
<div
onClick={() => onDateClick(day)}
onkeydown={(e) => handleKeydown(e, day, idx)}
ref={(ref) => (keyRef.current[idx] = ref)}
tabIndex={day.month === 0 ? day.date : null}
className={`h-12 w-12 float-left flex flex-shrink justify-center items-center cursor-pointer ${
day.month !== 0 ? ' opacity-50 bg-gray-700 dark:bg-gray-700 pointer-events-none' : ''
}
${isFirstDayInRange(day) ? ' rounded-l-xl ' : ''}
${isSelectedRange(day) ? ' bg-blue-600 dark:hover:bg-blue-600' : ''}
${isLastDayInRange(day) ? ' rounded-r-xl ' : ''}
${isCurrentDay(day) && !isLastDayInRange(day) ? 'rounded-full bg-gray-100 dark:hover:bg-gray-100 ' : ''}`}
key={idx}
>
<div className="font-light">
<span className="text-gray-400">{day.date}</span>
</div>
</div>
);
});
return (
<div>
<div className="w-full flex justify-start flex-shrink">
{['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'].map((d, i) => (
<div key={i} className="w-12 text-xs font-light text-center">
{d}
</div>
))}
</div>
<div className="w-full h-56">{days}</div>
</div>
);
};
return (
<div className="select-none w-96 flex flex-shrink" ref={calenderRef}>
<div className="py-4 px-6">
<div className="flex items-center">
<div className="w-1/6 relative flex justify-around">
<div
tabIndex={100}
className="flex justify-center items-center cursor-pointer absolute -mt-4 text-center rounded-full w-10 h-10 bg-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800"
onClick={() => setYear(-1)}
>
<ArrowRightDouble className="h-2/6 transform rotate-180 " />
</div>
</div>
<div className="w-1/6 relative flex justify-around ">
<div
tabIndex={101}
className="flex justify-center items-center cursor-pointer absolute -mt-4 text-center rounded-full w-10 h-10 bg-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800"
onClick={() => setMonth(-1)}
>
<ArrowRight className="h-2/6 transform rotate-180 red" />
</div>
</div>
<div className="w-1/3">
<div className="text-3xl text-center text-gray-200 font-extralight">{state.year}</div>
<div className="text-center text-gray-400 font-extralight">{getMonthStr(state.month)}</div>
</div>
<div className="w-1/6 relative flex justify-around ">
<div
tabIndex={102}
className="flex justify-center items-center cursor-pointer absolute -mt-4 text-center rounded-full w-10 h-10 bg-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800"
onClick={() => setMonth(1)}
>
<ArrowRight className="h-2/6" />
</div>
</div>
<div className="w-1/6 relative flex justify-around " tabIndex={104} onClick={() => setYear(1)}>
<div className="flex justify-center items-center cursor-pointer absolute -mt-4 text-center rounded-full w-10 h-10 bg-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800">
<ArrowRightDouble className="h-2/6" />
</div>
</div>
</div>
<div className="mt-3">{renderCalendar()}</div>
</div>
</div>
);
};
export default Calender;

View File

@@ -0,0 +1,162 @@
import { h } from 'preact';
import { useCallback, useEffect, useState } from 'preact/hooks';
export const DateFilterOptions = [
{
label: 'All',
value: ['all'],
},
{
label: 'Today',
value: {
//Before
before: new Date().setHours(24, 0, 0, 0) / 1000,
//After
after: new Date().setHours(0, 0, 0, 0) / 1000,
},
},
{
label: 'Yesterday',
value: {
//Before
before: new Date(new Date().setDate(new Date().getDate() - 1)).setHours(24, 0, 0, 0) / 1000,
//After
after: new Date(new Date().setDate(new Date().getDate() - 1)).setHours(0, 0, 0, 0) / 1000,
},
},
{
label: 'Last 7 Days',
value: {
//Before
before: new Date().setHours(24, 0, 0, 0) / 1000,
//After
after: new Date(new Date().setDate(new Date().getDate() - 7)).setHours(0, 0, 0, 0) / 1000,
},
},
{
label: 'This Month',
value: {
//Before
before: new Date().setHours(24, 0, 0, 0) / 1000,
//After
after: new Date(new Date().getFullYear(), new Date().getMonth(), 1).getTime() / 1000,
},
},
{
label: 'Last Month',
value: {
//Before
before: new Date(new Date().getFullYear(), new Date().getMonth(), 1).getTime() / 1000,
//After
after: new Date(new Date().getFullYear(), new Date().getMonth() - 1, 1).getTime() / 1000,
},
},
{
label: 'Custom Range',
value: 'custom_range',
},
];
export default function DatePicker({
helpText,
keyboardType = 'text',
inputRef,
label,
leadingIcon: LeadingIcon,
onBlur,
onChangeText,
onFocus,
readonly,
trailingIcon: TrailingIcon,
value: propValue = '',
...props
}) {
const [isFocused, setFocused] = useState(false);
const [value, setValue] = useState(propValue);
useEffect(() => {
if (propValue !== value) {
setValue(propValue);
}
}, [propValue, setValue, value]);
const handleFocus = useCallback(
(event) => {
setFocused(true);
onFocus && onFocus(event);
},
[onFocus]
);
const handleBlur = useCallback(
(event) => {
setFocused(false);
onBlur && onBlur(event);
},
[onBlur]
);
const handleChange = useCallback(
(event) => {
const { value } = event.target;
setValue(value);
onChangeText && onChangeText(value);
},
[onChangeText, setValue]
);
const onClick = (e) => {
props.onclick(e);
};
const labelMoved = isFocused || value !== '';
return (
<div className="w-full">
{props.children}
<div
className={`bg-gray-100 dark:bg-gray-700 rounded rounded-b-none border-gray-400 border-b p-1 pl-4 pr-3 ${
isFocused ? 'border-blue-500 dark:border-blue-500' : ''
}`}
ref={inputRef}
>
<label
className="flex space-x-2 items-center"
data-testid={`label-${label.toLowerCase().replace(/[^\w]+/g, '_')}`}
>
{LeadingIcon ? (
<div className="w-10 h-full">
<LeadingIcon />
</div>
) : null}
<div className="relative w-full">
<input
className="h-6 mt-6 w-full bg-transparent focus:outline-none focus:ring-0"
type={keyboardType}
readOnly
onBlur={handleBlur}
onFocus={handleFocus}
onInput={handleChange}
tabIndex="0"
onClick={onClick}
value={propValue}
{...props}
/>
<div
className={`absolute top-3 transition transform text-gray-600 dark:text-gray-400 ${
labelMoved ? 'text-xs -translate-y-2' : ''
} ${isFocused ? 'text-blue-500 dark:text-blue-500' : ''}`}
>
<p>{label}</p>
</div>
</div>
{TrailingIcon ? (
<div className="w-10 h-10">
<TrailingIcon />
</div>
) : null}
</label>
</div>
{helpText ? <div className="text-xs pl-3 pt-1">{helpText}</div> : null}
</div>
);
}

View File

@@ -27,7 +27,7 @@ export default function RelativeModal({
const handleKeydown = useCallback( 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;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,18 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function ArrowLeft({ className = '' }) {
return (
<svg
className={`fill-current ${className}`}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path d="M12 0c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm-1.218 19l-1.782-1.75 5.25-5.25-5.25-5.25 1.782-1.75 6.968 7-6.968 7z" />
</svg>
);
}
export default memo(ArrowLeft);

View File

@@ -0,0 +1,12 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function ArrowRight({ className = '' }) {
return (
<svg className={`fill-current ${className}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M5 3l3.057-3 11.943 12-11.943 12-3.057-3 9-9z" />
</svg>
);
}
export default memo(ArrowRight);

View File

@@ -0,0 +1,12 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function ArrowRightDouble({ className = '' }) {
return (
<svg className={`fill-current ${className}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M0 3.795l2.995-2.98 11.132 11.185-11.132 11.186-2.995-2.981 8.167-8.205-8.167-8.205zm18.04 8.205l-8.167 8.205 2.995 2.98 11.132-11.185-11.132-11.186-2.995 2.98 8.167 8.206z" />
</svg>
);
}
export default memo(ArrowRightDouble);

View File

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

View File

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

View File

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