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 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 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..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 @@ -17,14 +17,18 @@ 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 EventsDeleteBody(BaseModel): + event_ids: List[str] = Field(title="The event IDs to delete") class SubmitPlusBody(BaseModel): diff --git a/frigate/api/event.py b/frigate/api/event.py index bff1edc1a..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, @@ -35,8 +36,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__) @@ -1035,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") @@ -1087,9 +1119,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/app.py b/frigate/app.py index 6518c1ddf..34dcf3cd7 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 @@ -436,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, @@ -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/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: 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/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/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}" diff --git a/frigate/events/external.py b/frigate/events/external.py index 52ff5ffb7..0d3408975 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) @@ -107,6 +108,7 @@ class ExternalEventProcessor: EventTypeEnum.api, EventStateEnum.end, None, + "", {"id": event_id, "end_time": end_time}, ) ) @@ -131,8 +133,11 @@ class ExternalEventProcessor: label: str, event_id: str, draw: dict[str, any], - img_frame: any, - ) -> str: + img_frame: Optional[ndarray], + ) -> Optional[str]: + if img_frame is None: + 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: 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 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), 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) 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 ( -