From d25ffdb29276b94173fd162653a8379692767c39 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 29 Nov 2024 20:44:42 -0600 Subject: [PATCH 01/13] Fix crash when consecutive underscores are used in camera name (#15257) --- web/src/pages/Live.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/pages/Live.tsx b/web/src/pages/Live.tsx index e19dccd07..97e565ef1 100644 --- a/web/src/pages/Live.tsx +++ b/web/src/pages/Live.tsx @@ -62,6 +62,7 @@ function Live() { if (selectedCameraName) { const capitalized = selectedCameraName .split("_") + .filter((text) => text) .map((text) => text[0].toUpperCase() + text.substring(1)); document.title = `${capitalized.join(" ")} - Live - Frigate`; } else if (cameraGroup && cameraGroup != "default") { From f094c59cd0476e69b321436a8c0de8839046531a Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sat, 30 Nov 2024 18:21:50 -0600 Subject: [PATCH 02/13] Fix formatting (#15271) --- frigate/config/config.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/frigate/config/config.py b/frigate/config/config.py index 8fbb9ec6c..8c0b52e92 100644 --- a/frigate/config/config.py +++ b/frigate/config/config.py @@ -230,12 +230,16 @@ def verify_recording_segments_setup_with_reasonable_time( try: seg_arg_index = record_args.index("-segment_time") except ValueError: - raise ValueError(f"Camera {camera_config.name} has no segment_time in \ - recording output args, segment args are required for record.") + raise ValueError( + f"Camera {camera_config.name} has no segment_time in \ + recording output args, segment args are required for record." + ) if int(record_args[seg_arg_index + 1]) > 60: - raise ValueError(f"Camera {camera_config.name} has invalid segment_time output arg, \ - segment_time must be 60 or less.") + raise ValueError( + f"Camera {camera_config.name} has invalid segment_time output arg, \ + segment_time must be 60 or less." + ) def verify_zone_objects_are_tracked(camera_config: CameraConfig) -> None: From ee816b2251bf5f9abb7105c0976743a27628c0e8 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sat, 30 Nov 2024 18:22:36 -0600 Subject: [PATCH 03/13] Fix camera access and improve typing (#15272) * Fix camera access and improve typing: * Formatting --- frigate/api/event.py | 9 ++++++--- frigate/api/media.py | 23 +++++++++++++---------- frigate/events/external.py | 10 +++++++--- frigate/object_processing.py | 9 +++++++-- 4 files changed, 33 insertions(+), 18 deletions(-) diff --git a/frigate/api/event.py b/frigate/api/event.py index bff1edc1a..3b38ff072 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -35,8 +35,9 @@ from frigate.const import ( CLIPS_DIR, ) from frigate.embeddings import EmbeddingsContext +from frigate.events.external import ExternalEventProcessor from frigate.models import Event, ReviewSegment, Timeline -from frigate.object_processing import TrackedObject +from frigate.object_processing import TrackedObject, TrackedObjectProcessor from frigate.util.builtin import get_tz_modifiers logger = logging.getLogger(__name__) @@ -1087,9 +1088,11 @@ def create_event( ) try: - frame = request.app.detected_frames_processor.get_current_frame(camera_name) + frame_processor: TrackedObjectProcessor = request.app.detected_frames_processor + external_processor: ExternalEventProcessor = request.app.external_processor - event_id = request.app.external_processor.create_manual_event( + frame = frame_processor.get_current_frame(camera_name) + event_id = external_processor.create_manual_event( camera_name, label, body.source_type, diff --git a/frigate/api/media.py b/frigate/api/media.py index dcfc44f89..a90766899 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -36,6 +36,7 @@ from frigate.const import ( RECORD_DIR, ) from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment +from frigate.object_processing import TrackedObjectProcessor from frigate.util.builtin import get_tz_modifiers from frigate.util.image import get_image_from_recording @@ -79,7 +80,11 @@ def mjpeg_feed( def imagestream( - detected_frames_processor, camera_name: str, fps: int, height: int, draw_options + detected_frames_processor: TrackedObjectProcessor, + camera_name: str, + fps: int, + height: int, + draw_options: dict[str, any], ): while True: # max out at specified FPS @@ -118,6 +123,7 @@ def latest_frame( extension: Extension, params: MediaLatestFrameQueryParams = Depends(), ): + frame_processor: TrackedObjectProcessor = request.app.detected_frames_processor draw_options = { "bounding_boxes": params.bbox, "timestamp": params.timestamp, @@ -129,17 +135,14 @@ def latest_frame( quality = params.quality if camera_name in request.app.frigate_config.cameras: - frame = request.app.detected_frames_processor.get_current_frame( - camera_name, draw_options - ) + frame = frame_processor.get_current_frame(camera_name, draw_options) retry_interval = float( request.app.frigate_config.cameras.get(camera_name).ffmpeg.retry_interval or 10 ) if frame is None or datetime.now().timestamp() > ( - request.app.detected_frames_processor.get_current_frame_time(camera_name) - + retry_interval + frame_processor.get_current_frame_time(camera_name) + retry_interval ): if request.app.camera_error_image is None: error_image = glob.glob("/opt/frigate/frigate/images/camera-error.jpg") @@ -180,7 +183,7 @@ def latest_frame( ) elif camera_name == "birdseye" and request.app.frigate_config.birdseye.restream: frame = cv2.cvtColor( - request.app.detected_frames_processor.get_current_frame(camera_name), + frame_processor.get_current_frame(camera_name), cv2.COLOR_YUV2BGR_I420, ) @@ -813,15 +816,15 @@ def grid_snapshot( ): if camera_name in request.app.frigate_config.cameras: detect = request.app.frigate_config.cameras[camera_name].detect - frame = request.app.detected_frames_processor.get_current_frame(camera_name, {}) + frame_processor: TrackedObjectProcessor = request.app.detected_frames_processor + frame = frame_processor.get_current_frame(camera_name, {}) retry_interval = float( request.app.frigate_config.cameras.get(camera_name).ffmpeg.retry_interval or 10 ) if frame is None or datetime.now().timestamp() > ( - request.app.detected_frames_processor.get_current_frame_time(camera_name) - + retry_interval + frame_processor.get_current_frame_time(camera_name) + retry_interval ): return JSONResponse( content={"success": False, "message": "Unable to get valid frame"}, diff --git a/frigate/events/external.py b/frigate/events/external.py index 52ff5ffb7..922917bb4 100644 --- a/frigate/events/external.py +++ b/frigate/events/external.py @@ -10,6 +10,7 @@ from enum import Enum from typing import Optional import cv2 +from numpy import ndarray from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum from frigate.comms.events_updater import EventUpdatePublisher @@ -45,7 +46,7 @@ class ExternalEventProcessor: duration: Optional[int], include_recording: bool, draw: dict[str, any], - snapshot_frame: any, + snapshot_frame: Optional[ndarray], ) -> str: now = datetime.datetime.now().timestamp() camera_config = self.config.cameras.get(camera) @@ -131,8 +132,11 @@ class ExternalEventProcessor: label: str, event_id: str, draw: dict[str, any], - img_frame: any, - ) -> str: + img_frame: Optional[ndarray], + ) -> Optional[str]: + if not img_frame: + return None + # write clean snapshot if enabled if camera_config.snapshots.clean_copy: ret, png = cv2.imencode(".png", img_frame) diff --git a/frigate/object_processing.py b/frigate/object_processing.py index 937c935ba..ef23c3de3 100644 --- a/frigate/object_processing.py +++ b/frigate/object_processing.py @@ -6,7 +6,7 @@ import queue import threading from collections import Counter, defaultdict from multiprocessing.synchronize import Event as MpEvent -from typing import Callable +from typing import Callable, Optional import cv2 import numpy as np @@ -784,13 +784,18 @@ class TrackedObjectProcessor(threading.Thread): else: return {} - def get_current_frame(self, camera, draw_options={}): + def get_current_frame( + self, camera: str, draw_options: dict[str, any] = {} + ) -> Optional[np.ndarray]: if camera == "birdseye": return self.frame_manager.get( "birdseye", (self.config.birdseye.height * 3 // 2, self.config.birdseye.width), ) + if camera not in self.camera_states: + return None + return self.camera_states[camera].get_current_frame(draw_options) def get_current_frame_time(self, camera) -> int: From 71e8f75a01eaedb4f4795b05b4a4145cb53bfb79 Mon Sep 17 00:00:00 2001 From: Alessandro Genova Date: Sat, 30 Nov 2024 19:27:21 -0500 Subject: [PATCH 04/13] Let the docker container spend more time to clean up and shut down (docs) (#15275) --- docs/docs/frigate/installation.md | 2 ++ docs/docs/guides/getting_started.md | 1 + 2 files changed, 3 insertions(+) diff --git a/docs/docs/frigate/installation.md b/docs/docs/frigate/installation.md index e3599e628..ede0fa897 100644 --- a/docs/docs/frigate/installation.md +++ b/docs/docs/frigate/installation.md @@ -193,6 +193,7 @@ services: container_name: frigate privileged: true # this may not be necessary for all setups restart: unless-stopped + stop_grace_period: 30s # allow enough time to shut down the various services image: ghcr.io/blakeblackshear/frigate:stable shm_size: "512mb" # update for your cameras based on calculation above devices: @@ -224,6 +225,7 @@ If you can't use docker compose, you can run the container with something simila docker run -d \ --name frigate \ --restart=unless-stopped \ + --stop-timeout 30 \ --mount type=tmpfs,target=/tmp/cache,tmpfs-size=1000000000 \ --device /dev/bus/usb:/dev/bus/usb \ --device /dev/dri/renderD128 \ diff --git a/docs/docs/guides/getting_started.md b/docs/docs/guides/getting_started.md index 829612bb0..bb880b8f0 100644 --- a/docs/docs/guides/getting_started.md +++ b/docs/docs/guides/getting_started.md @@ -115,6 +115,7 @@ services: frigate: container_name: frigate restart: unless-stopped + stop_grace_period: 30s image: ghcr.io/blakeblackshear/frigate:stable volumes: - ./config:/config From 5802a664690a662c1cae152cacde1bfea20fe528 Mon Sep 17 00:00:00 2001 From: tpjanssen <25168870+tpjanssen@users.noreply.github.com> Date: Sun, 1 Dec 2024 15:47:37 +0100 Subject: [PATCH 05/13] Fix audio events in explore section (#15286) * Fix audio events in explore section Make sure that audio events are listed in the explore section * Update audio.py * Hide other submit options Only allow submits for objects only --- docs/static/frigate-api.yaml | 4 ++-- frigate/api/defs/events_body.py | 4 ++-- frigate/events/audio.py | 4 ++++ frigate/events/external.py | 1 + web/src/components/menu/SearchResultActions.tsx | 2 ++ web/src/components/overlay/detail/ReviewDetailDialog.tsx | 1 + web/src/components/overlay/detail/SearchDetailDialog.tsx | 2 +- 7 files changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/static/frigate-api.yaml b/docs/static/frigate-api.yaml index 325af2850..5e4ecdbdc 100644 --- a/docs/static/frigate-api.yaml +++ b/docs/static/frigate-api.yaml @@ -3225,7 +3225,7 @@ components: title: Sub Label score: anyOf: - - type: integer + - type: number - type: 'null' title: Score default: 0 @@ -3264,7 +3264,7 @@ components: properties: end_time: anyOf: - - type: integer + - type: number - type: 'null' title: End Time type: object diff --git a/frigate/api/defs/events_body.py b/frigate/api/defs/events_body.py index ca1256598..db2b4060b 100644 --- a/frigate/api/defs/events_body.py +++ b/frigate/api/defs/events_body.py @@ -17,14 +17,14 @@ class EventsDescriptionBody(BaseModel): class EventsCreateBody(BaseModel): source_type: Optional[str] = "api" sub_label: Optional[str] = None - score: Optional[int] = 0 + score: Optional[float] = 0 duration: Optional[int] = 30 include_recording: Optional[bool] = True draw: Optional[dict] = {} class EventsEndBody(BaseModel): - end_time: Optional[int] = None + end_time: Optional[float] = None class SubmitPlusBody(BaseModel): diff --git a/frigate/events/audio.py b/frigate/events/audio.py index 80d035894..7675f821b 100644 --- a/frigate/events/audio.py +++ b/frigate/events/audio.py @@ -216,6 +216,10 @@ class AudioEventMaintainer(threading.Thread): "label": label, "last_detection": datetime.datetime.now().timestamp(), } + else: + self.logger.warning( + f"Failed to create audio event with status code {resp.status_code}" + ) def expire_detections(self) -> None: now = datetime.datetime.now().timestamp() diff --git a/frigate/events/external.py b/frigate/events/external.py index 922917bb4..02671b207 100644 --- a/frigate/events/external.py +++ b/frigate/events/external.py @@ -108,6 +108,7 @@ class ExternalEventProcessor: EventTypeEnum.api, EventStateEnum.end, None, + "", {"id": event_id, "end_time": end_time}, ) ) diff --git a/web/src/components/menu/SearchResultActions.tsx b/web/src/components/menu/SearchResultActions.tsx index 10f0ed623..277ce2169 100644 --- a/web/src/components/menu/SearchResultActions.tsx +++ b/web/src/components/menu/SearchResultActions.tsx @@ -128,6 +128,7 @@ export default function SearchResultActions({ config?.plus?.enabled && searchResult.has_snapshot && searchResult.end_time && + searchResult.data.type == "object" && !searchResult.plus_id && ( @@ -197,6 +198,7 @@ export default function SearchResultActions({ config?.plus?.enabled && searchResult.has_snapshot && searchResult.end_time && + searchResult.data.type == "object" && !searchResult.plus_id && ( diff --git a/web/src/components/overlay/detail/ReviewDetailDialog.tsx b/web/src/components/overlay/detail/ReviewDetailDialog.tsx index 74a9950b9..c3e7ac91d 100644 --- a/web/src/components/overlay/detail/ReviewDetailDialog.tsx +++ b/web/src/components/overlay/detail/ReviewDetailDialog.tsx @@ -379,6 +379,7 @@ function EventItem({ {event.has_snapshot && event.plus_id == undefined && + event.data.type == "object" && config?.plus.enabled && ( diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index f7af31606..f63dffcc1 100644 --- a/web/src/components/overlay/detail/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -626,7 +626,7 @@ export function ObjectSnapshotTab({ )} - {search.plus_id !== "not_enabled" && search.end_time && ( + {search.data.type == "object" && search.plus_id !== "not_enabled" && search.end_time && (
From 002fdeae67d40a9d25087cb69f981e98cede2236 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sun, 1 Dec 2024 10:39:35 -0600 Subject: [PATCH 06/13] SHM tweaks (#15274) * Use env var to control max number of frames * Handle type * Fix frame_name not being sent * Formatting --- frigate/app.py | 6 +++++- frigate/const.py | 2 ++ frigate/review/maintainer.py | 4 +++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/frigate/app.py b/frigate/app.py index 6518c1ddf..ba82757f9 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -36,6 +36,7 @@ from frigate.const import ( EXPORT_DIR, MODEL_CACHE_DIR, RECORD_DIR, + SHM_FRAMES_VAR, ) from frigate.db.sqlitevecq import SqliteVecQueueDatabase from frigate.embeddings import EmbeddingsContext, manage_embeddings @@ -523,7 +524,10 @@ class FrigateApp: if cam_total_frame_size == 0.0: return 0 - shm_frame_count = min(200, int(available_shm / (cam_total_frame_size))) + shm_frame_count = min( + int(os.environ.get(SHM_FRAMES_VAR, "50")), + int(available_shm / (cam_total_frame_size)), + ) logger.debug( f"Calculated total camera size {available_shm} / {cam_total_frame_size} :: {shm_frame_count} frames for each camera in SHM" diff --git a/frigate/const.py b/frigate/const.py index c83b10e73..5976f47b1 100644 --- a/frigate/const.py +++ b/frigate/const.py @@ -13,6 +13,8 @@ FRIGATE_LOCALHOST = "http://127.0.0.1:5000" PLUS_ENV_VAR = "PLUS_API_KEY" PLUS_API_HOST = "https://api.frigate.video" +SHM_FRAMES_VAR = "SHM_MAX_FRAMES" + # Attribute & Object constants DEFAULT_ATTRIBUTE_LABEL_MAP = { diff --git a/frigate/review/maintainer.py b/frigate/review/maintainer.py index 23a42e7a7..de137cb26 100644 --- a/frigate/review/maintainer.py +++ b/frigate/review/maintainer.py @@ -480,7 +480,9 @@ class ReviewSegmentMaintainer(threading.Thread): if not self.config.cameras[camera].record.enabled: if current_segment: - self.update_existing_segment(current_segment, frame_time, []) + self.update_existing_segment( + current_segment, frame_name, frame_time, [] + ) continue From 4a5fe4138e206fe425bfccaafa61910e297035bd Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 1 Dec 2024 12:08:03 -0600 Subject: [PATCH 07/13] Explore audio event tweaks (#15291) --- .../components/menu/SearchResultActions.tsx | 39 +++--- .../overlay/detail/SearchDetailDialog.tsx | 120 +++++++++--------- 2 files changed, 82 insertions(+), 77 deletions(-) diff --git a/web/src/components/menu/SearchResultActions.tsx b/web/src/components/menu/SearchResultActions.tsx index 277ce2169..fee12a50f 100644 --- a/web/src/components/menu/SearchResultActions.tsx +++ b/web/src/components/menu/SearchResultActions.tsx @@ -108,13 +108,15 @@ export default function SearchResultActions({ )} - - - View object lifecycle - + {searchResult.data.type == "object" && ( + + + View object lifecycle + + )} {config?.semantic_search?.enabled && isContextMenu && ( ) : ( <> - {config?.semantic_search?.enabled && ( - - - - - Find similar - - )} + {config?.semantic_search?.enabled && + searchResult.data.type == "object" && ( + + + + + Find similar + + )} {!isMobileOnly && config?.plus?.enabled && diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index f63dffcc1..b0eeac98d 100644 --- a/web/src/components/overlay/detail/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -452,7 +452,7 @@ function ObjectDetailsTab({ draggable={false} src={`${apiHost}api/events/${search.id}/thumbnail.jpg`} /> - {config?.semantic_search.enabled && ( + {config?.semantic_search.enabled && search.data.type == "object" && (
)} - {search.data.type == "object" && search.plus_id !== "not_enabled" && search.end_time && ( - - -
-
- Submit To Frigate+ -
-
- Objects in locations you want to avoid are not false - positives. Submitting them as false positives will confuse - the model. -
-
- -
- {state == "reviewing" && ( - <> - - - - )} - {state == "uploading" && } - {state == "submitted" && ( -
- - Submitted + {search.data.type == "object" && + search.plus_id !== "not_enabled" && + search.end_time && ( + + +
+
+ Submit To Frigate+
- )} -
-
-
- )} +
+ Objects in locations you want to avoid are not false + positives. Submitting them as false positives will + confuse the model. +
+
+ +
+ {state == "reviewing" && ( + <> + + + + )} + {state == "uploading" && } + {state == "submitted" && ( +
+ + Submitted +
+ )} +
+ + + )}
From a1fa9decaddc539e6d2748782d72248f34c746d4 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 1 Dec 2024 12:37:45 -0600 Subject: [PATCH 08/13] Fix event cleanup debug logging crash (#15293) --- frigate/events/cleanup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frigate/events/cleanup.py b/frigate/events/cleanup.py index 8ae38b534..5400cc660 100644 --- a/frigate/events/cleanup.py +++ b/frigate/events/cleanup.py @@ -110,7 +110,7 @@ class EventCleanup(threading.Thread): .namedtuples() .iterator() ) - logger.debug(f"{len(expired_events)} events can be expired") + logger.debug(f"{len(list(expired_events))} events can be expired") # delete the media from disk for expired in expired_events: media_name = f"{expired.camera}-{expired.id}" From c95bc9fe44ba299f611033c63f98b6cdc16b2801 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Sun, 1 Dec 2024 13:33:10 -0600 Subject: [PATCH 09/13] Handle case where camera name ends in number (#15296) --- frigate/app.py | 2 +- frigate/video.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frigate/app.py b/frigate/app.py index ba82757f9..34dcf3cd7 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -437,7 +437,7 @@ class FrigateApp: # pre-create shms for i in range(shm_frame_count): frame_size = config.frame_shape_yuv[0] * config.frame_shape_yuv[1] - self.frame_manager.create(f"{config.name}{i}", frame_size) + self.frame_manager.create(f"{config.name}_{i}", frame_size) capture_process = util.Process( target=capture_camera, diff --git a/frigate/video.py b/frigate/video.py index 96b562e8c..d8ff1a869 100755 --- a/frigate/video.py +++ b/frigate/video.py @@ -113,7 +113,7 @@ def capture_frames( fps.value = frame_rate.eps() skipped_fps.value = skipped_eps.eps() current_frame.value = datetime.datetime.now().timestamp() - frame_name = f"{config.name}{frame_index}" + frame_name = f"{config.name}_{frame_index}" frame_buffer = frame_manager.write(frame_name) try: frame_buffer[:] = ffmpeg_process.stdout.read(frame_size) From 833cdcb6d2a17a13377ef469218eec524ddc58a7 Mon Sep 17 00:00:00 2001 From: James Livulpi Date: Sun, 1 Dec 2024 21:07:44 -0500 Subject: [PATCH 10/13] fix audio event create (#15299) --- frigate/events/external.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frigate/events/external.py b/frigate/events/external.py index 02671b207..0d3408975 100644 --- a/frigate/events/external.py +++ b/frigate/events/external.py @@ -135,7 +135,7 @@ class ExternalEventProcessor: draw: dict[str, any], img_frame: Optional[ndarray], ) -> Optional[str]: - if not img_frame: + if img_frame is None: return None # write clean snapshot if enabled From 5475672a9defd1991a92ee9bcd09101af97dd975 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20=C4=8Cerm=C3=A1k?= Date: Mon, 2 Dec 2024 15:35:51 +0100 Subject: [PATCH 11/13] Fix extraction of Hailo userspace libs (#15187) The archive already has everything contained in a rootfs folder, extract it as-is to the root folder. This also reverts changes from 33957e53600afd0e1a677d6575917955d156dd4a which addressed the same issue in a less optimal way. --- docker/hailo8l/Dockerfile | 3 --- docker/hailo8l/install_hailort.sh | 4 +--- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/docker/hailo8l/Dockerfile b/docker/hailo8l/Dockerfile index 68ab09001..959e7692e 100644 --- a/docker/hailo8l/Dockerfile +++ b/docker/hailo8l/Dockerfile @@ -36,8 +36,5 @@ RUN pip3 install -U /deps/hailo-wheels/*.whl # Copy base files from the rootfs stage COPY --from=rootfs / / -# Set Library path for hailo driver -ENV LD_LIBRARY_PATH=/rootfs/usr/local/lib/ - # Set workdir WORKDIR /opt/frigate/ diff --git a/docker/hailo8l/install_hailort.sh b/docker/hailo8l/install_hailort.sh index 004db86c9..62eba9611 100755 --- a/docker/hailo8l/install_hailort.sh +++ b/docker/hailo8l/install_hailort.sh @@ -10,10 +10,8 @@ elif [[ "${TARGETARCH}" == "arm64" ]]; then arch="aarch64" fi -mkdir -p /rootfs - wget -qO- "https://github.com/frigate-nvr/hailort/releases/download/v${hailo_version}/hailort-${TARGETARCH}.tar.gz" | - tar -C /rootfs/ -xzf - + tar -C / -xzf - mkdir -p /hailo-wheels From 5f42caad03bd17f462836b721c7db7f7241d3b08 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 2 Dec 2024 12:12:55 -0600 Subject: [PATCH 12/13] Explore bulk actions (#15307) * use id instead of index for object details and scrolling * long press package and hook * fix long press in review * search action group * multi select in explore * add bulk deletion to backend api * clean up * mimic behavior of review * don't open dialog on left click when mutli selecting * context menu on container ref * revert long press code * clean up --- frigate/api/defs/events_body.py | 6 +- frigate/api/event.py | 59 ++++- web/package-lock.json | 10 + web/package.json | 1 + web/src/components/card/SearchThumbnail.tsx | 18 +- .../components/filter/SearchActionGroup.tsx | 132 ++++++++++ web/src/hooks/use-press.ts | 54 ++++ web/src/views/explore/ExploreView.tsx | 10 +- web/src/views/search/SearchView.tsx | 245 ++++++++++++++---- 9 files changed, 452 insertions(+), 83 deletions(-) create mode 100644 web/src/components/filter/SearchActionGroup.tsx create mode 100644 web/src/hooks/use-press.ts diff --git a/frigate/api/defs/events_body.py b/frigate/api/defs/events_body.py index db2b4060b..1c8576f02 100644 --- a/frigate/api/defs/events_body.py +++ b/frigate/api/defs/events_body.py @@ -1,4 +1,4 @@ -from typing import Optional, Union +from typing import List, Optional, Union from pydantic import BaseModel, Field @@ -27,5 +27,9 @@ class EventsEndBody(BaseModel): end_time: Optional[float] = None +class EventsDeleteBody(BaseModel): + event_ids: List[str] = Field(title="The event IDs to delete") + + class SubmitPlusBody(BaseModel): include_annotation: int = Field(default=1) diff --git a/frigate/api/event.py b/frigate/api/event.py index 3b38ff072..fafa28272 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -16,6 +16,7 @@ from playhouse.shortcuts import model_to_dict from frigate.api.defs.events_body import ( EventsCreateBody, + EventsDeleteBody, EventsDescriptionBody, EventsEndBody, EventsSubLabelBody, @@ -1036,34 +1037,64 @@ def regenerate_description( ) -@router.delete("/events/{event_id}") -def delete_event(request: Request, event_id: str): +def delete_single_event(event_id: str, request: Request) -> dict: try: event = Event.get(Event.id == event_id) except DoesNotExist: - return JSONResponse( - content=({"success": False, "message": "Event " + event_id + " not found"}), - status_code=404, - ) + return {"success": False, "message": f"Event {event_id} not found"} media_name = f"{event.camera}-{event.id}" if event.has_snapshot: - media = Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg") - media.unlink(missing_ok=True) - media = Path(f"{os.path.join(CLIPS_DIR, media_name)}-clean.png") - media.unlink(missing_ok=True) + snapshot_paths = [ + Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg"), + Path(f"{os.path.join(CLIPS_DIR, media_name)}-clean.png"), + ] + for media in snapshot_paths: + media.unlink(missing_ok=True) event.delete_instance() Timeline.delete().where(Timeline.source_id == event_id).execute() + # If semantic search is enabled, update the index if request.app.frigate_config.semantic_search.enabled: context: EmbeddingsContext = request.app.embeddings context.db.delete_embeddings_thumbnail(event_ids=[event_id]) context.db.delete_embeddings_description(event_ids=[event_id]) - return JSONResponse( - content=({"success": True, "message": "Event " + event_id + " deleted"}), - status_code=200, - ) + + return {"success": True, "message": f"Event {event_id} deleted"} + + +@router.delete("/events/{event_id}") +def delete_event(request: Request, event_id: str): + result = delete_single_event(event_id, request) + status_code = 200 if result["success"] else 404 + return JSONResponse(content=result, status_code=status_code) + + +@router.delete("/events/") +def delete_events(request: Request, body: EventsDeleteBody): + if not body.event_ids: + return JSONResponse( + content=({"success": False, "message": "No event IDs provided."}), + status_code=404, + ) + + deleted_events = [] + not_found_events = [] + + for event_id in body.event_ids: + result = delete_single_event(event_id, request) + if result["success"]: + deleted_events.append(event_id) + else: + not_found_events.append(event_id) + + response = { + "success": True, + "deleted_events": deleted_events, + "not_found_events": not_found_events, + } + return JSONResponse(content=response, status_code=200) @router.post("/events/{camera_name}/{label}/create") diff --git a/web/package-lock.json b/web/package-lock.json index a0971c361..7ce6345af 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -72,6 +72,7 @@ "tailwind-merge": "^2.4.0", "tailwind-scrollbar": "^3.1.0", "tailwindcss-animate": "^1.0.7", + "use-long-press": "^3.2.0", "vaul": "^0.9.1", "vite-plugin-monaco-editor": "^1.1.0", "zod": "^3.23.8" @@ -8709,6 +8710,15 @@ "scheduler": ">=0.19.0" } }, + "node_modules/use-long-press": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/use-long-press/-/use-long-press-3.2.0.tgz", + "integrity": "sha512-uq5o2qFR1VRjHn8Of7Fl344/AGvgk7C5Mcb4aSb1ZRVp6PkgdXJJLdRrlSTJQVkkQcDuqFbFc3mDX4COg7mRTA==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/use-sidecar": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", diff --git a/web/package.json b/web/package.json index 73b2ed309..d76e6ad10 100644 --- a/web/package.json +++ b/web/package.json @@ -78,6 +78,7 @@ "tailwind-merge": "^2.4.0", "tailwind-scrollbar": "^3.1.0", "tailwindcss-animate": "^1.0.7", + "use-long-press": "^3.2.0", "vaul": "^0.9.1", "vite-plugin-monaco-editor": "^1.1.0", "zod": "^3.23.8" diff --git a/web/src/components/card/SearchThumbnail.tsx b/web/src/components/card/SearchThumbnail.tsx index e96632400..7dfa7b583 100644 --- a/web/src/components/card/SearchThumbnail.tsx +++ b/web/src/components/card/SearchThumbnail.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from "react"; +import { useMemo } from "react"; import { useApiHost } from "@/api"; import { getIconForLabel } from "@/utils/iconUtil"; import useSWR from "swr"; @@ -12,10 +12,11 @@ import { capitalizeFirstLetter } from "@/utils/stringUtil"; import { SearchResult } from "@/types/search"; import { cn } from "@/lib/utils"; import { TooltipPortal } from "@radix-ui/react-tooltip"; +import useContextMenu from "@/hooks/use-contextmenu"; type SearchThumbnailProps = { searchResult: SearchResult; - onClick: (searchResult: SearchResult) => void; + onClick: (searchResult: SearchResult, ctrl: boolean, detail: boolean) => void; }; export default function SearchThumbnail({ @@ -28,9 +29,9 @@ export default function SearchThumbnail({ // interactions - const handleOnClick = useCallback(() => { - onClick(searchResult); - }, [searchResult, onClick]); + useContextMenu(imgRef, () => { + onClick(searchResult, true, false); + }); const objectLabel = useMemo(() => { if ( @@ -45,7 +46,10 @@ export default function SearchThumbnail({ }, [config, searchResult]); return ( -
+
onClick(searchResult, false, true)} + > onClick(searchResult)} + onClick={() => onClick(searchResult, false, true)} > {getIconForLabel(objectLabel, "size-3 text-white")} {Math.round( diff --git a/web/src/components/filter/SearchActionGroup.tsx b/web/src/components/filter/SearchActionGroup.tsx new file mode 100644 index 000000000..aac03ad1c --- /dev/null +++ b/web/src/components/filter/SearchActionGroup.tsx @@ -0,0 +1,132 @@ +import { useCallback, useState } from "react"; +import axios from "axios"; +import { Button, buttonVariants } from "../ui/button"; +import { isDesktop } from "react-device-detect"; +import { HiTrash } from "react-icons/hi"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "../ui/alert-dialog"; +import useKeyboardListener from "@/hooks/use-keyboard-listener"; +import { toast } from "sonner"; + +type SearchActionGroupProps = { + selectedObjects: string[]; + setSelectedObjects: (ids: string[]) => void; + pullLatestData: () => void; +}; +export default function SearchActionGroup({ + selectedObjects, + setSelectedObjects, + pullLatestData, +}: SearchActionGroupProps) { + const onClearSelected = useCallback(() => { + setSelectedObjects([]); + }, [setSelectedObjects]); + + const onDelete = useCallback(async () => { + await axios + .delete(`events/`, { + data: { event_ids: selectedObjects }, + }) + .then((resp) => { + if (resp.status == 200) { + toast.success("Tracked objects deleted successfully.", { + position: "top-center", + }); + setSelectedObjects([]); + pullLatestData(); + } + }) + .catch(() => { + toast.error("Failed to delete tracked objects.", { + position: "top-center", + }); + }); + }, [selectedObjects, setSelectedObjects, pullLatestData]); + + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [bypassDialog, setBypassDialog] = useState(false); + + useKeyboardListener(["Shift"], (_, modifiers) => { + setBypassDialog(modifiers.shift); + }); + + const handleDelete = useCallback(() => { + if (bypassDialog) { + onDelete(); + } else { + setDeleteDialogOpen(true); + } + }, [bypassDialog, onDelete]); + + return ( + <> + setDeleteDialogOpen(!deleteDialogOpen)} + > + + + Confirm Delete + + + Deleting these {selectedObjects.length} tracked objects removes the + snapshot, any saved embeddings, and any associated object lifecycle + entries. Recorded footage of these tracked objects in History view + will NOT be deleted. +
+
+ Are you sure you want to proceed? +
+
+ Hold the Shift key to bypass this dialog in the future. +
+ + Cancel + + Delete + + +
+
+ +
+
+
{`${selectedObjects.length} selected`}
+
{"|"}
+
+ Unselect +
+
+
+ +
+
+ + ); +} diff --git a/web/src/hooks/use-press.ts b/web/src/hooks/use-press.ts new file mode 100644 index 000000000..6e97ce11b --- /dev/null +++ b/web/src/hooks/use-press.ts @@ -0,0 +1,54 @@ +// https://gist.github.com/cpojer/641bf305e6185006ea453e7631b80f95 + +import { useCallback, useState } from "react"; +import { + LongPressCallbackMeta, + LongPressReactEvents, + useLongPress, +} from "use-long-press"; + +export default function usePress( + options: Omit[1], "onCancel" | "onStart"> & { + onLongPress: NonNullable[0]>; + onPress: (event: LongPressReactEvents) => void; + }, +) { + const { onLongPress, onPress, ...actualOptions } = options; + const [hasLongPress, setHasLongPress] = useState(false); + + const onCancel = useCallback(() => { + if (hasLongPress) { + setHasLongPress(false); + } + }, [hasLongPress]); + + const bind = useLongPress( + useCallback( + ( + event: LongPressReactEvents, + meta: LongPressCallbackMeta, + ) => { + setHasLongPress(true); + onLongPress(event, meta); + }, + [onLongPress], + ), + { + ...actualOptions, + onCancel, + onStart: onCancel, + }, + ); + + return useCallback( + () => ({ + ...bind(), + onClick: (event: LongPressReactEvents) => { + if (!hasLongPress) { + onPress(event); + } + }, + }), + [bind, hasLongPress, onPress], + ); +} diff --git a/web/src/views/explore/ExploreView.tsx b/web/src/views/explore/ExploreView.tsx index ea9c3cbef..0ec825416 100644 --- a/web/src/views/explore/ExploreView.tsx +++ b/web/src/views/explore/ExploreView.tsx @@ -26,7 +26,7 @@ type ExploreViewProps = { searchDetail: SearchResult | undefined; setSearchDetail: (search: SearchResult | undefined) => void; setSimilaritySearch: (search: SearchResult) => void; - onSelectSearch: (item: SearchResult, index: number, page?: SearchTab) => void; + onSelectSearch: (item: SearchResult, ctrl: boolean, page?: SearchTab) => void; }; export default function ExploreView({ @@ -125,7 +125,7 @@ type ThumbnailRowType = { setSearchDetail: (search: SearchResult | undefined) => void; mutate: () => void; setSimilaritySearch: (search: SearchResult) => void; - onSelectSearch: (item: SearchResult, index: number, page?: SearchTab) => void; + onSelectSearch: (item: SearchResult, ctrl: boolean, page?: SearchTab) => void; }; function ThumbnailRow({ @@ -205,7 +205,7 @@ type ExploreThumbnailImageProps = { setSearchDetail: (search: SearchResult | undefined) => void; mutate: () => void; setSimilaritySearch: (search: SearchResult) => void; - onSelectSearch: (item: SearchResult, index: number, page?: SearchTab) => void; + onSelectSearch: (item: SearchResult, ctrl: boolean, page?: SearchTab) => void; }; function ExploreThumbnailImage({ event, @@ -225,11 +225,11 @@ function ExploreThumbnailImage({ }; const handleShowObjectLifecycle = () => { - onSelectSearch(event, 0, "object lifecycle"); + onSelectSearch(event, false, "object lifecycle"); }; const handleShowSnapshot = () => { - onSelectSearch(event, 0, "snapshot"); + onSelectSearch(event, false, "snapshot"); }; return ( diff --git a/web/src/views/search/SearchView.tsx b/web/src/views/search/SearchView.tsx index 378b313e0..be430f134 100644 --- a/web/src/views/search/SearchView.tsx +++ b/web/src/views/search/SearchView.tsx @@ -30,6 +30,7 @@ import { } from "@/components/ui/tooltip"; import Chip from "@/components/indicators/Chip"; import { TooltipPortal } from "@radix-ui/react-tooltip"; +import SearchActionGroup from "@/components/filter/SearchActionGroup"; type SearchViewProps = { search: string; @@ -181,20 +182,53 @@ export default function SearchView({ // search interaction - const [selectedIndex, setSelectedIndex] = useState(null); + const [selectedObjects, setSelectedObjects] = useState([]); const itemRefs = useRef<(HTMLDivElement | null)[]>([]); const onSelectSearch = useCallback( - (item: SearchResult, index: number, page: SearchTab = "details") => { - setPage(page); - setSearchDetail(item); - setSelectedIndex(index); + (item: SearchResult, ctrl: boolean, page: SearchTab = "details") => { + if (selectedObjects.length > 1 || ctrl) { + const index = selectedObjects.indexOf(item.id); + + if (index != -1) { + if (selectedObjects.length == 1) { + setSelectedObjects([]); + } else { + const copy = [ + ...selectedObjects.slice(0, index), + ...selectedObjects.slice(index + 1), + ]; + setSelectedObjects(copy); + } + } else { + const copy = [...selectedObjects]; + copy.push(item.id); + setSelectedObjects(copy); + } + } else { + setPage(page); + setSearchDetail(item); + } }, - [], + [selectedObjects], ); + const onSelectAllObjects = useCallback(() => { + if (!uniqueResults || uniqueResults.length == 0) { + return; + } + + if (selectedObjects.length < uniqueResults.length) { + setSelectedObjects(uniqueResults.map((value) => value.id)); + } else { + setSelectedObjects([]); + } + }, [uniqueResults, selectedObjects]); + useEffect(() => { - setSelectedIndex(0); + setSelectedObjects([]); + // unselect items when search term or filter changes + // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchTerm, searchFilter]); // confidence score @@ -243,23 +277,44 @@ export default function SearchView({ } switch (key) { - case "ArrowLeft": - setSelectedIndex((prevIndex) => { - const newIndex = - prevIndex === null - ? uniqueResults.length - 1 - : (prevIndex - 1 + uniqueResults.length) % uniqueResults.length; - setSearchDetail(uniqueResults[newIndex]); - return newIndex; - }); + case "a": + if (modifiers.ctrl) { + onSelectAllObjects(); + } break; - case "ArrowRight": - setSelectedIndex((prevIndex) => { + case "ArrowLeft": + if (uniqueResults.length > 0) { + const currentIndex = searchDetail + ? uniqueResults.findIndex( + (result) => result.id === searchDetail.id, + ) + : -1; + const newIndex = - prevIndex === null ? 0 : (prevIndex + 1) % uniqueResults.length; + currentIndex === -1 + ? uniqueResults.length - 1 + : (currentIndex - 1 + uniqueResults.length) % + uniqueResults.length; + setSearchDetail(uniqueResults[newIndex]); - return newIndex; - }); + } + break; + + case "ArrowRight": + if (uniqueResults.length > 0) { + const currentIndex = searchDetail + ? uniqueResults.findIndex( + (result) => result.id === searchDetail.id, + ) + : -1; + + const newIndex = + currentIndex === -1 + ? 0 + : (currentIndex + 1) % uniqueResults.length; + + setSearchDetail(uniqueResults[newIndex]); + } break; case "PageDown": contentRef.current?.scrollBy({ @@ -275,32 +330,80 @@ export default function SearchView({ break; } }, - [uniqueResults, inputFocused], + [uniqueResults, inputFocused, onSelectAllObjects, searchDetail], ); useKeyboardListener( - ["ArrowLeft", "ArrowRight", "PageDown", "PageUp"], + ["a", "ArrowLeft", "ArrowRight", "PageDown", "PageUp"], onKeyboardShortcut, !inputFocused, ); // scroll into view + const [prevSearchDetail, setPrevSearchDetail] = useState< + SearchResult | undefined + >(); + + // keep track of previous ref to outline thumbnail when dialog closes + const prevSearchDetailRef = useRef(); + useEffect(() => { - if ( - selectedIndex !== null && - uniqueResults && - itemRefs.current?.[selectedIndex] - ) { - scrollIntoView(itemRefs.current[selectedIndex], { - block: "center", - behavior: "smooth", - scrollMode: "if-needed", - }); + if (searchDetail === undefined && prevSearchDetailRef.current) { + setPrevSearchDetail(prevSearchDetailRef.current); } - // we only want to scroll when the index changes + prevSearchDetailRef.current = searchDetail; + }, [searchDetail]); + + useEffect(() => { + if (uniqueResults && itemRefs.current && prevSearchDetail) { + const selectedIndex = uniqueResults.findIndex( + (result) => result.id === prevSearchDetail.id, + ); + + const parent = itemRefs.current[selectedIndex]; + + if (selectedIndex !== -1 && parent) { + const target = parent.querySelector(".review-item-ring"); + if (target) { + scrollIntoView(target, { + block: "center", + behavior: "smooth", + scrollMode: "if-needed", + }); + target.classList.add(`outline-selected`); + target.classList.remove("outline-transparent"); + + setTimeout(() => { + target.classList.remove(`outline-selected`); + target.classList.add("outline-transparent"); + }, 3000); + } + } + } + // we only want to scroll when the dialog closes // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedIndex]); + }, [prevSearchDetail]); + + useEffect(() => { + if (uniqueResults && itemRefs.current && searchDetail) { + const selectedIndex = uniqueResults.findIndex( + (result) => result.id === searchDetail.id, + ); + + const parent = itemRefs.current[selectedIndex]; + + if (selectedIndex !== -1 && parent) { + scrollIntoView(parent, { + block: "center", + behavior: "smooth", + scrollMode: "if-needed", + }); + } + } + // we only want to scroll when changing the detail pane + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchDetail]); // observer for loading more @@ -369,22 +472,39 @@ export default function SearchView({ {hasExistingSearch && (
- - - + {selectedObjects.length == 0 ? ( + <> + + + + + ) : ( +
+ +
+ )}
)} @@ -412,14 +532,14 @@ export default function SearchView({
{uniqueResults && uniqueResults.map((value, index) => { - const selected = selectedIndex === index; + const selected = selectedObjects.includes(value.id); return (
(itemRefs.current[index] = item)} data-start={value.start_time} - className="review-item relative flex flex-col rounded-lg" + className="relative flex flex-col rounded-lg" >
onSelectSearch(value, index)} + onClick={( + value: SearchResult, + ctrl: boolean, + detail: boolean, + ) => { + if (detail && selectedObjects.length == 0) { + setSearchDetail(value); + } else { + onSelectSearch( + value, + ctrl || selectedObjects.length > 0, + ); + } + }} /> {(searchTerm || searchFilter?.search_type?.includes("similarity")) && ( @@ -469,10 +602,10 @@ export default function SearchView({ }} refreshResults={refresh} showObjectLifecycle={() => - onSelectSearch(value, index, "object lifecycle") + onSelectSearch(value, false, "object lifecycle") } showSnapshot={() => - onSelectSearch(value, index, "snapshot") + onSelectSearch(value, false, "snapshot") } />
From 4dddc537350f7908a5dfdb42ecd4929c0b02ddba Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 2 Dec 2024 13:07:12 -0600 Subject: [PATCH 13/13] move label placement when overlapping small boxes (#15310) --- frigate/util/image.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/frigate/util/image.py b/frigate/util/image.py index 7b22c138e..301da9c6a 100644 --- a/frigate/util/image.py +++ b/frigate/util/image.py @@ -222,16 +222,25 @@ def draw_box_with_label( # set the text start position if position == "ul": text_offset_x = x_min - text_offset_y = 0 if y_min < line_height else y_min - (line_height + 8) + text_offset_y = max(0, y_min - (line_height + 8)) elif position == "ur": - text_offset_x = x_max - (text_width + 8) - text_offset_y = 0 if y_min < line_height else y_min - (line_height + 8) + text_offset_x = max(0, x_max - (text_width + 8)) + text_offset_y = max(0, y_min - (line_height + 8)) elif position == "bl": text_offset_x = x_min text_offset_y = y_max elif position == "br": - text_offset_x = x_max - (text_width + 8) + text_offset_x = max(0, x_max - (text_width + 8)) text_offset_y = y_max + + # Adjust position if it overlaps with the box + if position in {"ul", "ur"} and text_offset_y < y_min + thickness: + # Move the text below the box + text_offset_y = y_max + elif position in {"bl", "br"} and text_offset_y + line_height > y_max: + # Move the text above the box + text_offset_y = max(0, y_min - (line_height + 8)) + # make the coords of the box with a small padding of two pixels textbox_coords = ( (text_offset_x, text_offset_y),