mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +01:00
Ability to manually create events through the API (#3184)
* Move to events package * Improve handling of external events * Handle external events in the event queue * Pass in event processor * Check event json * Fix json parsing and change defaults * Fix snapshot saving * Hide % score when not available * Correct docs and add json example * Save event png db * Adjust image * Formatting * Add catch for failure ending event * Add init to modules * Fix naming * Formatting * Fix http creation * fix test * Change to PUT and include response in docs * Add ability to set bounding box locations in snapshot * Support multiple box annotations * Cleanup docs example response Co-authored-by: Blake Blackshear <blake@frigate.video> * Cleanup docs wording Co-authored-by: Blake Blackshear <blake@frigate.video> * Store full frame for thumbnail * Formatting * Set thumbnail height to 175 * Formatting --------- Co-authored-by: Blake Blackshear <blake@frigate.video>
This commit is contained in:
parent
6d0c2ec5c8
commit
e357715a8c
@ -295,3 +295,41 @@ Get ffprobe output for camera feed paths.
|
|||||||
### `GET /api/<camera_name>/ptz/info`
|
### `GET /api/<camera_name>/ptz/info`
|
||||||
|
|
||||||
Get PTZ info for the camera.
|
Get PTZ info for the camera.
|
||||||
|
|
||||||
|
### `POST /api/events/<camera_name>/<label>/create`
|
||||||
|
|
||||||
|
Create a manual API with a given `label` (ex: doorbell press) to capture a specific event besides an object being detected.
|
||||||
|
|
||||||
|
**Optional Body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"subLabel": "some_string", // add sub label to event
|
||||||
|
"duration": 30, // predetermined length of event (default: 30 seconds) or can be to null for indeterminate length event
|
||||||
|
"include_recording": true, // whether the event should save recordings along with the snapshot that is taken
|
||||||
|
"draw": {
|
||||||
|
// optional annotations that will be drawn on the snapshot
|
||||||
|
"boxes": [
|
||||||
|
{
|
||||||
|
"box": [0.5, 0.5, 0.25, 0.25], // box consists of x, y, width, height which are on a scale between 0 - 1
|
||||||
|
"color": [255, 0, 0], // color of the box, default is red
|
||||||
|
"score": 100 // optional score associated with the box
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_id": "1682970645.13116-1ug7ns",
|
||||||
|
"message": "Successfully created event.",
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `PUT /api/events/<event_id>/end`
|
||||||
|
|
||||||
|
End a specific manual event without a predetermined length.
|
||||||
|
@ -28,7 +28,9 @@ from frigate.const import (
|
|||||||
RECORD_DIR,
|
RECORD_DIR,
|
||||||
)
|
)
|
||||||
from frigate.object_detection import ObjectDetectProcess
|
from frigate.object_detection import ObjectDetectProcess
|
||||||
from frigate.events import EventCleanup, EventProcessor
|
from frigate.events.cleanup import EventCleanup
|
||||||
|
from frigate.events.external import ExternalEventProcessor
|
||||||
|
from frigate.events.maintainer import EventProcessor
|
||||||
from frigate.http import create_app
|
from frigate.http import create_app
|
||||||
from frigate.log import log_process, root_configurer
|
from frigate.log import log_process, root_configurer
|
||||||
from frigate.models import Event, Recordings, Timeline
|
from frigate.models import Event, Recordings, Timeline
|
||||||
@ -204,6 +206,11 @@ class FrigateApp:
|
|||||||
self.config, self.camera_metrics, self.detectors, self.processes
|
self.config, self.camera_metrics, self.detectors, self.processes
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def init_external_event_processor(self) -> None:
|
||||||
|
self.external_event_processor = ExternalEventProcessor(
|
||||||
|
self.config, self.event_queue
|
||||||
|
)
|
||||||
|
|
||||||
def init_web_server(self) -> None:
|
def init_web_server(self) -> None:
|
||||||
self.flask_app = create_app(
|
self.flask_app = create_app(
|
||||||
self.config,
|
self.config,
|
||||||
@ -212,6 +219,7 @@ class FrigateApp:
|
|||||||
self.detected_frames_processor,
|
self.detected_frames_processor,
|
||||||
self.storage_maintainer,
|
self.storage_maintainer,
|
||||||
self.onvif_controller,
|
self.onvif_controller,
|
||||||
|
self.external_event_processor,
|
||||||
self.plus_api,
|
self.plus_api,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -436,6 +444,7 @@ class FrigateApp:
|
|||||||
self.start_camera_capture_processes()
|
self.start_camera_capture_processes()
|
||||||
self.start_storage_maintainer()
|
self.start_storage_maintainer()
|
||||||
self.init_stats()
|
self.init_stats()
|
||||||
|
self.init_external_event_processor()
|
||||||
self.init_web_server()
|
self.init_web_server()
|
||||||
self.start_timeline_processor()
|
self.start_timeline_processor()
|
||||||
self.start_event_processor()
|
self.start_event_processor()
|
||||||
|
0
frigate/events/__init__.py
Normal file
0
frigate/events/__init__.py
Normal file
176
frigate/events/cleanup.py
Normal file
176
frigate/events/cleanup.py
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
"""Cleanup events based on configured retention."""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from peewee import fn
|
||||||
|
|
||||||
|
from frigate.config import FrigateConfig
|
||||||
|
from frigate.const import CLIPS_DIR
|
||||||
|
from frigate.models import Event
|
||||||
|
|
||||||
|
from multiprocessing.synchronize import Event as MpEvent
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class EventCleanup(threading.Thread):
|
||||||
|
def __init__(self, config: FrigateConfig, stop_event: MpEvent):
|
||||||
|
threading.Thread.__init__(self)
|
||||||
|
self.name = "event_cleanup"
|
||||||
|
self.config = config
|
||||||
|
self.stop_event = stop_event
|
||||||
|
self.camera_keys = list(self.config.cameras.keys())
|
||||||
|
|
||||||
|
def expire(self, media_type: str) -> None:
|
||||||
|
# TODO: Refactor media_type to enum
|
||||||
|
## Expire events from unlisted cameras based on the global config
|
||||||
|
if media_type == "clips":
|
||||||
|
retain_config = self.config.record.events.retain
|
||||||
|
file_extension = "mp4"
|
||||||
|
update_params = {"has_clip": False}
|
||||||
|
else:
|
||||||
|
retain_config = self.config.snapshots.retain
|
||||||
|
file_extension = "jpg"
|
||||||
|
update_params = {"has_snapshot": False}
|
||||||
|
|
||||||
|
distinct_labels = (
|
||||||
|
Event.select(Event.label)
|
||||||
|
.where(Event.camera.not_in(self.camera_keys))
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
# loop over object types in db
|
||||||
|
for l in distinct_labels:
|
||||||
|
# get expiration time for this label
|
||||||
|
expire_days = retain_config.objects.get(l.label, retain_config.default)
|
||||||
|
expire_after = (
|
||||||
|
datetime.datetime.now() - datetime.timedelta(days=expire_days)
|
||||||
|
).timestamp()
|
||||||
|
# grab all events after specific time
|
||||||
|
expired_events = Event.select().where(
|
||||||
|
Event.camera.not_in(self.camera_keys),
|
||||||
|
Event.start_time < expire_after,
|
||||||
|
Event.label == l.label,
|
||||||
|
Event.retain_indefinitely == False,
|
||||||
|
)
|
||||||
|
# delete the media from disk
|
||||||
|
for event in expired_events:
|
||||||
|
media_name = f"{event.camera}-{event.id}"
|
||||||
|
media_path = Path(
|
||||||
|
f"{os.path.join(CLIPS_DIR, media_name)}.{file_extension}"
|
||||||
|
)
|
||||||
|
media_path.unlink(missing_ok=True)
|
||||||
|
if file_extension == "jpg":
|
||||||
|
media_path = Path(
|
||||||
|
f"{os.path.join(CLIPS_DIR, media_name)}-clean.png"
|
||||||
|
)
|
||||||
|
media_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
# update the clips attribute for the db entry
|
||||||
|
update_query = Event.update(update_params).where(
|
||||||
|
Event.camera.not_in(self.camera_keys),
|
||||||
|
Event.start_time < expire_after,
|
||||||
|
Event.label == l.label,
|
||||||
|
Event.retain_indefinitely == False,
|
||||||
|
)
|
||||||
|
update_query.execute()
|
||||||
|
|
||||||
|
## Expire events from cameras based on the camera config
|
||||||
|
for name, camera in self.config.cameras.items():
|
||||||
|
if media_type == "clips":
|
||||||
|
retain_config = camera.record.events.retain
|
||||||
|
else:
|
||||||
|
retain_config = camera.snapshots.retain
|
||||||
|
# get distinct objects in database for this camera
|
||||||
|
distinct_labels = (
|
||||||
|
Event.select(Event.label).where(Event.camera == name).distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
# loop over object types in db
|
||||||
|
for l in distinct_labels:
|
||||||
|
# get expiration time for this label
|
||||||
|
expire_days = retain_config.objects.get(l.label, retain_config.default)
|
||||||
|
expire_after = (
|
||||||
|
datetime.datetime.now() - datetime.timedelta(days=expire_days)
|
||||||
|
).timestamp()
|
||||||
|
# grab all events after specific time
|
||||||
|
expired_events = Event.select().where(
|
||||||
|
Event.camera == name,
|
||||||
|
Event.start_time < expire_after,
|
||||||
|
Event.label == l.label,
|
||||||
|
Event.retain_indefinitely == False,
|
||||||
|
)
|
||||||
|
# delete the grabbed clips from disk
|
||||||
|
for event in expired_events:
|
||||||
|
media_name = f"{event.camera}-{event.id}"
|
||||||
|
media_path = Path(
|
||||||
|
f"{os.path.join(CLIPS_DIR, media_name)}.{file_extension}"
|
||||||
|
)
|
||||||
|
media_path.unlink(missing_ok=True)
|
||||||
|
if file_extension == "jpg":
|
||||||
|
media_path = Path(
|
||||||
|
f"{os.path.join(CLIPS_DIR, media_name)}-clean.png"
|
||||||
|
)
|
||||||
|
media_path.unlink(missing_ok=True)
|
||||||
|
# update the clips attribute for the db entry
|
||||||
|
update_query = Event.update(update_params).where(
|
||||||
|
Event.camera == name,
|
||||||
|
Event.start_time < expire_after,
|
||||||
|
Event.label == l.label,
|
||||||
|
Event.retain_indefinitely == False,
|
||||||
|
)
|
||||||
|
update_query.execute()
|
||||||
|
|
||||||
|
def purge_duplicates(self) -> None:
|
||||||
|
duplicate_query = """with grouped_events as (
|
||||||
|
select id,
|
||||||
|
label,
|
||||||
|
camera,
|
||||||
|
has_snapshot,
|
||||||
|
has_clip,
|
||||||
|
row_number() over (
|
||||||
|
partition by label, camera, round(start_time/5,0)*5
|
||||||
|
order by end_time-start_time desc
|
||||||
|
) as copy_number
|
||||||
|
from event
|
||||||
|
)
|
||||||
|
|
||||||
|
select distinct id, camera, has_snapshot, has_clip from grouped_events
|
||||||
|
where copy_number > 1;"""
|
||||||
|
|
||||||
|
duplicate_events = Event.raw(duplicate_query)
|
||||||
|
for event in duplicate_events:
|
||||||
|
logger.debug(f"Removing duplicate: {event.id}")
|
||||||
|
media_name = f"{event.camera}-{event.id}"
|
||||||
|
media_path = Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg")
|
||||||
|
media_path.unlink(missing_ok=True)
|
||||||
|
media_path = Path(f"{os.path.join(CLIPS_DIR, media_name)}-clean.png")
|
||||||
|
media_path.unlink(missing_ok=True)
|
||||||
|
media_path = Path(f"{os.path.join(CLIPS_DIR, media_name)}.mp4")
|
||||||
|
media_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
(
|
||||||
|
Event.delete()
|
||||||
|
.where(Event.id << [event.id for event in duplicate_events])
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
# only expire events every 5 minutes
|
||||||
|
while not self.stop_event.wait(300):
|
||||||
|
self.expire("clips")
|
||||||
|
self.expire("snapshots")
|
||||||
|
self.purge_duplicates()
|
||||||
|
|
||||||
|
# drop events from db where has_clip and has_snapshot are false
|
||||||
|
delete_query = Event.delete().where(
|
||||||
|
Event.has_clip == False, Event.has_snapshot == False
|
||||||
|
)
|
||||||
|
delete_query.execute()
|
||||||
|
|
||||||
|
logger.info(f"Exiting event cleanup...")
|
132
frigate/events/external.py
Normal file
132
frigate/events/external.py
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
"""Handle external events created by the user."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import cv2
|
||||||
|
import datetime
|
||||||
|
import glob
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from multiprocessing.queues import Queue
|
||||||
|
|
||||||
|
from frigate.config import CameraConfig, FrigateConfig
|
||||||
|
from frigate.const import CLIPS_DIR
|
||||||
|
from frigate.events.maintainer import EventTypeEnum
|
||||||
|
from frigate.util import draw_box_with_label
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalEventProcessor:
|
||||||
|
def __init__(self, config: FrigateConfig, queue: Queue) -> None:
|
||||||
|
self.config = config
|
||||||
|
self.queue = queue
|
||||||
|
self.default_thumbnail = None
|
||||||
|
|
||||||
|
def create_manual_event(
|
||||||
|
self,
|
||||||
|
camera: str,
|
||||||
|
label: str,
|
||||||
|
sub_label: Optional[str],
|
||||||
|
duration: Optional[int],
|
||||||
|
include_recording: bool,
|
||||||
|
draw: dict[str, any],
|
||||||
|
snapshot_frame: any,
|
||||||
|
) -> str:
|
||||||
|
now = datetime.datetime.now().timestamp()
|
||||||
|
camera_config = self.config.cameras.get(camera)
|
||||||
|
|
||||||
|
# create event id and start frame time
|
||||||
|
rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
|
||||||
|
event_id = f"{now}-{rand_id}"
|
||||||
|
|
||||||
|
thumbnail = self._write_images(
|
||||||
|
camera_config, label, event_id, draw, snapshot_frame
|
||||||
|
)
|
||||||
|
|
||||||
|
self.queue.put(
|
||||||
|
(
|
||||||
|
EventTypeEnum.api,
|
||||||
|
"new",
|
||||||
|
camera_config,
|
||||||
|
{
|
||||||
|
"id": event_id,
|
||||||
|
"label": label,
|
||||||
|
"sub_label": sub_label,
|
||||||
|
"camera": camera,
|
||||||
|
"start_time": now,
|
||||||
|
"end_time": now + duration if duration is not None else None,
|
||||||
|
"thumbnail": thumbnail,
|
||||||
|
"has_clip": camera_config.record.enabled and include_recording,
|
||||||
|
"has_snapshot": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return event_id
|
||||||
|
|
||||||
|
def finish_manual_event(self, event_id: str) -> None:
|
||||||
|
"""Finish external event with indeterminate duration."""
|
||||||
|
now = datetime.datetime.now().timestamp()
|
||||||
|
self.queue.put(
|
||||||
|
(EventTypeEnum.api, "end", None, {"id": event_id, "end_time": now})
|
||||||
|
)
|
||||||
|
|
||||||
|
def _write_images(
|
||||||
|
self,
|
||||||
|
camera_config: CameraConfig,
|
||||||
|
label: str,
|
||||||
|
event_id: str,
|
||||||
|
draw: dict[str, any],
|
||||||
|
img_frame: any,
|
||||||
|
) -> str:
|
||||||
|
# write clean snapshot if enabled
|
||||||
|
if camera_config.snapshots.clean_copy:
|
||||||
|
ret, png = cv2.imencode(".png", img_frame)
|
||||||
|
|
||||||
|
if ret:
|
||||||
|
with open(
|
||||||
|
os.path.join(
|
||||||
|
CLIPS_DIR,
|
||||||
|
f"{camera_config.name}-{event_id}-clean.png",
|
||||||
|
),
|
||||||
|
"wb",
|
||||||
|
) as p:
|
||||||
|
p.write(png.tobytes())
|
||||||
|
|
||||||
|
# write jpg snapshot with optional annotations
|
||||||
|
if draw.get("boxes") and isinstance(draw.get("boxes"), list):
|
||||||
|
for box in draw.get("boxes"):
|
||||||
|
x = box["box"][0] * camera_config.detect.width
|
||||||
|
y = box["box"][1] * camera_config.detect.height
|
||||||
|
width = box["box"][2] * camera_config.detect.width
|
||||||
|
height = box["box"][3] * camera_config.detect.height
|
||||||
|
|
||||||
|
draw_box_with_label(
|
||||||
|
img_frame,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
x + width,
|
||||||
|
y + height,
|
||||||
|
label,
|
||||||
|
f"{box.get('score', '-')}% {int(width * height)}",
|
||||||
|
thickness=2,
|
||||||
|
color=box.get("color", (255, 0, 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
ret, jpg = cv2.imencode(".jpg", img_frame)
|
||||||
|
with open(
|
||||||
|
os.path.join(CLIPS_DIR, f"{camera_config.name}-{event_id}.jpg"),
|
||||||
|
"wb",
|
||||||
|
) as j:
|
||||||
|
j.write(jpg.tobytes())
|
||||||
|
|
||||||
|
# create thumbnail with max height of 175 and save
|
||||||
|
width = int(175 * img_frame.shape[1] / img_frame.shape[0])
|
||||||
|
thumb = cv2.resize(img_frame, dsize=(width, 175), interpolation=cv2.INTER_AREA)
|
||||||
|
ret, jpg = cv2.imencode(".jpg", thumb)
|
||||||
|
return base64.b64encode(jpg.tobytes()).decode("utf-8")
|
@ -1,16 +1,13 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import queue
|
import queue
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from peewee import fn
|
from peewee import fn
|
||||||
|
|
||||||
from frigate.config import EventsConfig, FrigateConfig
|
from frigate.config import EventsConfig, FrigateConfig
|
||||||
from frigate.const import CLIPS_DIR
|
|
||||||
from frigate.models import Event
|
from frigate.models import Event
|
||||||
from frigate.types import CameraMetricsTypes
|
from frigate.types import CameraMetricsTypes
|
||||||
from frigate.util import to_relative_box
|
from frigate.util import to_relative_box
|
||||||
@ -23,7 +20,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class EventTypeEnum(str, Enum):
|
class EventTypeEnum(str, Enum):
|
||||||
# api = "api"
|
api = "api"
|
||||||
# audio = "audio"
|
# audio = "audio"
|
||||||
tracked_object = "tracked_object"
|
tracked_object = "tracked_object"
|
||||||
|
|
||||||
@ -97,6 +94,8 @@ class EventProcessor(threading.Thread):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
self.handle_object_detection(event_type, camera, event_data)
|
self.handle_object_detection(event_type, camera, event_data)
|
||||||
|
elif source_type == EventTypeEnum.api:
|
||||||
|
self.handle_external_detection(event_type, event_data)
|
||||||
|
|
||||||
# set an end_time on events without an end_time before exiting
|
# set an end_time on events without an end_time before exiting
|
||||||
Event.update(end_time=datetime.datetime.now().timestamp()).where(
|
Event.update(end_time=datetime.datetime.now().timestamp()).where(
|
||||||
@ -197,160 +196,35 @@ class EventProcessor(threading.Thread):
|
|||||||
del self.events_in_process[event_data["id"]]
|
del self.events_in_process[event_data["id"]]
|
||||||
self.event_processed_queue.put((event_data["id"], camera))
|
self.event_processed_queue.put((event_data["id"], camera))
|
||||||
|
|
||||||
|
def handle_external_detection(self, type: str, event_data: Event):
|
||||||
|
if type == "new":
|
||||||
|
event = {
|
||||||
|
Event.id: event_data["id"],
|
||||||
|
Event.label: event_data["label"],
|
||||||
|
Event.sub_label: event_data["sub_label"],
|
||||||
|
Event.camera: event_data["camera"],
|
||||||
|
Event.start_time: event_data["start_time"],
|
||||||
|
Event.end_time: event_data["end_time"],
|
||||||
|
Event.thumbnail: event_data["thumbnail"],
|
||||||
|
Event.has_clip: event_data["has_clip"],
|
||||||
|
Event.has_snapshot: event_data["has_snapshot"],
|
||||||
|
Event.zones: [],
|
||||||
|
Event.data: {},
|
||||||
|
}
|
||||||
|
elif type == "end":
|
||||||
|
event = {
|
||||||
|
Event.id: event_data["id"],
|
||||||
|
Event.end_time: event_data["end_time"],
|
||||||
|
}
|
||||||
|
|
||||||
class EventCleanup(threading.Thread):
|
try:
|
||||||
def __init__(self, config: FrigateConfig, stop_event: MpEvent):
|
|
||||||
threading.Thread.__init__(self)
|
|
||||||
self.name = "event_cleanup"
|
|
||||||
self.config = config
|
|
||||||
self.stop_event = stop_event
|
|
||||||
self.camera_keys = list(self.config.cameras.keys())
|
|
||||||
|
|
||||||
def expire(self, media_type: str) -> None:
|
|
||||||
# TODO: Refactor media_type to enum
|
|
||||||
## Expire events from unlisted cameras based on the global config
|
|
||||||
if media_type == "clips":
|
|
||||||
retain_config = self.config.record.events.retain
|
|
||||||
file_extension = "mp4"
|
|
||||||
update_params = {"has_clip": False}
|
|
||||||
else:
|
|
||||||
retain_config = self.config.snapshots.retain
|
|
||||||
file_extension = "jpg"
|
|
||||||
update_params = {"has_snapshot": False}
|
|
||||||
|
|
||||||
distinct_labels = (
|
|
||||||
Event.select(Event.label)
|
|
||||||
.where(Event.camera.not_in(self.camera_keys))
|
|
||||||
.distinct()
|
|
||||||
)
|
|
||||||
|
|
||||||
# loop over object types in db
|
|
||||||
for l in distinct_labels:
|
|
||||||
# get expiration time for this label
|
|
||||||
expire_days = retain_config.objects.get(l.label, retain_config.default)
|
|
||||||
expire_after = (
|
|
||||||
datetime.datetime.now() - datetime.timedelta(days=expire_days)
|
|
||||||
).timestamp()
|
|
||||||
# grab all events after specific time
|
|
||||||
expired_events = Event.select().where(
|
|
||||||
Event.camera.not_in(self.camera_keys),
|
|
||||||
Event.start_time < expire_after,
|
|
||||||
Event.label == l.label,
|
|
||||||
Event.retain_indefinitely == False,
|
|
||||||
)
|
|
||||||
# delete the media from disk
|
|
||||||
for event in expired_events:
|
|
||||||
media_name = f"{event.camera}-{event.id}"
|
|
||||||
media_path = Path(
|
|
||||||
f"{os.path.join(CLIPS_DIR, media_name)}.{file_extension}"
|
|
||||||
)
|
|
||||||
media_path.unlink(missing_ok=True)
|
|
||||||
if file_extension == "jpg":
|
|
||||||
media_path = Path(
|
|
||||||
f"{os.path.join(CLIPS_DIR, media_name)}-clean.png"
|
|
||||||
)
|
|
||||||
media_path.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
# update the clips attribute for the db entry
|
|
||||||
update_query = Event.update(update_params).where(
|
|
||||||
Event.camera.not_in(self.camera_keys),
|
|
||||||
Event.start_time < expire_after,
|
|
||||||
Event.label == l.label,
|
|
||||||
Event.retain_indefinitely == False,
|
|
||||||
)
|
|
||||||
update_query.execute()
|
|
||||||
|
|
||||||
## Expire events from cameras based on the camera config
|
|
||||||
for name, camera in self.config.cameras.items():
|
|
||||||
if media_type == "clips":
|
|
||||||
retain_config = camera.record.events.retain
|
|
||||||
else:
|
|
||||||
retain_config = camera.snapshots.retain
|
|
||||||
# get distinct objects in database for this camera
|
|
||||||
distinct_labels = (
|
|
||||||
Event.select(Event.label).where(Event.camera == name).distinct()
|
|
||||||
)
|
|
||||||
|
|
||||||
# loop over object types in db
|
|
||||||
for l in distinct_labels:
|
|
||||||
# get expiration time for this label
|
|
||||||
expire_days = retain_config.objects.get(l.label, retain_config.default)
|
|
||||||
expire_after = (
|
|
||||||
datetime.datetime.now() - datetime.timedelta(days=expire_days)
|
|
||||||
).timestamp()
|
|
||||||
# grab all events after specific time
|
|
||||||
expired_events = Event.select().where(
|
|
||||||
Event.camera == name,
|
|
||||||
Event.start_time < expire_after,
|
|
||||||
Event.label == l.label,
|
|
||||||
Event.retain_indefinitely == False,
|
|
||||||
)
|
|
||||||
# delete the grabbed clips from disk
|
|
||||||
for event in expired_events:
|
|
||||||
media_name = f"{event.camera}-{event.id}"
|
|
||||||
media_path = Path(
|
|
||||||
f"{os.path.join(CLIPS_DIR, media_name)}.{file_extension}"
|
|
||||||
)
|
|
||||||
media_path.unlink(missing_ok=True)
|
|
||||||
if file_extension == "jpg":
|
|
||||||
media_path = Path(
|
|
||||||
f"{os.path.join(CLIPS_DIR, media_name)}-clean.png"
|
|
||||||
)
|
|
||||||
media_path.unlink(missing_ok=True)
|
|
||||||
# update the clips attribute for the db entry
|
|
||||||
update_query = Event.update(update_params).where(
|
|
||||||
Event.camera == name,
|
|
||||||
Event.start_time < expire_after,
|
|
||||||
Event.label == l.label,
|
|
||||||
Event.retain_indefinitely == False,
|
|
||||||
)
|
|
||||||
update_query.execute()
|
|
||||||
|
|
||||||
def purge_duplicates(self) -> None:
|
|
||||||
duplicate_query = """with grouped_events as (
|
|
||||||
select id,
|
|
||||||
label,
|
|
||||||
camera,
|
|
||||||
has_snapshot,
|
|
||||||
has_clip,
|
|
||||||
row_number() over (
|
|
||||||
partition by label, camera, round(start_time/5,0)*5
|
|
||||||
order by end_time-start_time desc
|
|
||||||
) as copy_number
|
|
||||||
from event
|
|
||||||
)
|
|
||||||
|
|
||||||
select distinct id, camera, has_snapshot, has_clip from grouped_events
|
|
||||||
where copy_number > 1;"""
|
|
||||||
|
|
||||||
duplicate_events = Event.raw(duplicate_query)
|
|
||||||
for event in duplicate_events:
|
|
||||||
logger.debug(f"Removing duplicate: {event.id}")
|
|
||||||
media_name = f"{event.camera}-{event.id}"
|
|
||||||
media_path = Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg")
|
|
||||||
media_path.unlink(missing_ok=True)
|
|
||||||
media_path = Path(f"{os.path.join(CLIPS_DIR, media_name)}-clean.png")
|
|
||||||
media_path.unlink(missing_ok=True)
|
|
||||||
media_path = Path(f"{os.path.join(CLIPS_DIR, media_name)}.mp4")
|
|
||||||
media_path.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
(
|
(
|
||||||
Event.delete()
|
Event.insert(event)
|
||||||
.where(Event.id << [event.id for event in duplicate_events])
|
.on_conflict(
|
||||||
|
conflict_target=[Event.id],
|
||||||
|
update=event,
|
||||||
|
)
|
||||||
.execute()
|
.execute()
|
||||||
)
|
)
|
||||||
|
except Exception:
|
||||||
def run(self) -> None:
|
logger.warning(f"Failed to update manual event: {event_data['id']}")
|
||||||
# only expire events every 5 minutes
|
|
||||||
while not self.stop_event.wait(300):
|
|
||||||
self.expire("clips")
|
|
||||||
self.expire("snapshots")
|
|
||||||
self.purge_duplicates()
|
|
||||||
|
|
||||||
# drop events from db where has_clip and has_snapshot are false
|
|
||||||
delete_query = Event.delete().where(
|
|
||||||
Event.has_clip == False, Event.has_snapshot == False
|
|
||||||
)
|
|
||||||
delete_query.execute()
|
|
||||||
|
|
||||||
logger.info(f"Exiting event cleanup...")
|
|
@ -1,8 +1,8 @@
|
|||||||
import base64
|
import base64
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
import copy
|
import copy
|
||||||
import glob
|
|
||||||
import logging
|
import logging
|
||||||
|
import glob
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess as sp
|
import subprocess as sp
|
||||||
@ -34,6 +34,7 @@ from playhouse.shortcuts import model_to_dict
|
|||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.const import CLIPS_DIR, MAX_SEGMENT_DURATION, RECORD_DIR
|
from frigate.const import CLIPS_DIR, MAX_SEGMENT_DURATION, RECORD_DIR
|
||||||
from frigate.models import Event, Recordings, Timeline
|
from frigate.models import Event, Recordings, Timeline
|
||||||
|
from frigate.events.external import ExternalEventProcessor
|
||||||
from frigate.object_processing import TrackedObject
|
from frigate.object_processing import TrackedObject
|
||||||
from frigate.plus import PlusApi
|
from frigate.plus import PlusApi
|
||||||
from frigate.ptz import OnvifController
|
from frigate.ptz import OnvifController
|
||||||
@ -60,6 +61,7 @@ def create_app(
|
|||||||
detected_frames_processor,
|
detected_frames_processor,
|
||||||
storage_maintainer: StorageMaintainer,
|
storage_maintainer: StorageMaintainer,
|
||||||
onvif: OnvifController,
|
onvif: OnvifController,
|
||||||
|
external_processor: ExternalEventProcessor,
|
||||||
plus_api: PlusApi,
|
plus_api: PlusApi,
|
||||||
):
|
):
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
@ -79,6 +81,7 @@ def create_app(
|
|||||||
app.detected_frames_processor = detected_frames_processor
|
app.detected_frames_processor = detected_frames_processor
|
||||||
app.storage_maintainer = storage_maintainer
|
app.storage_maintainer = storage_maintainer
|
||||||
app.onvif = onvif
|
app.onvif = onvif
|
||||||
|
app.external_processor = external_processor
|
||||||
app.plus_api = plus_api
|
app.plus_api = plus_api
|
||||||
app.camera_error_image = None
|
app.camera_error_image = None
|
||||||
app.hwaccel_errors = []
|
app.hwaccel_errors = []
|
||||||
@ -195,7 +198,7 @@ def send_to_plus(id):
|
|||||||
return make_response(jsonify({"success": False, "message": message}), 404)
|
return make_response(jsonify({"success": False, "message": message}), 404)
|
||||||
|
|
||||||
# events from before the conversion to relative dimensions cant include annotations
|
# events from before the conversion to relative dimensions cant include annotations
|
||||||
if any(d > 1 for d in event.data["box"]):
|
if any(d > 1 for d in event.box):
|
||||||
include_annotation = None
|
include_annotation = None
|
||||||
|
|
||||||
if event.end_time is None:
|
if event.end_time is None:
|
||||||
@ -251,8 +254,8 @@ def send_to_plus(id):
|
|||||||
event.save()
|
event.save()
|
||||||
|
|
||||||
if not include_annotation is None:
|
if not include_annotation is None:
|
||||||
region = event.data["region"]
|
region = event.region
|
||||||
box = event.data["box"]
|
box = event.box
|
||||||
|
|
||||||
try:
|
try:
|
||||||
current_app.plus_api.add_annotation(
|
current_app.plus_api.add_annotation(
|
||||||
@ -293,7 +296,7 @@ def false_positive(id):
|
|||||||
return make_response(jsonify({"success": False, "message": message}), 404)
|
return make_response(jsonify({"success": False, "message": message}), 404)
|
||||||
|
|
||||||
# events from before the conversion to relative dimensions cant include annotations
|
# events from before the conversion to relative dimensions cant include annotations
|
||||||
if any(d > 1 for d in event.data["box"]):
|
if any(d > 1 for d in event.box):
|
||||||
message = f"Events prior to 0.13 cannot be submitted as false positives"
|
message = f"Events prior to 0.13 cannot be submitted as false positives"
|
||||||
logger.error(message)
|
logger.error(message)
|
||||||
return make_response(jsonify({"success": False, "message": message}), 400)
|
return make_response(jsonify({"success": False, "message": message}), 400)
|
||||||
@ -310,15 +313,11 @@ def false_positive(id):
|
|||||||
# need to refetch the event now that it has a plus_id
|
# need to refetch the event now that it has a plus_id
|
||||||
event = Event.get(Event.id == id)
|
event = Event.get(Event.id == id)
|
||||||
|
|
||||||
region = event.data["region"]
|
region = event.region
|
||||||
box = event.data["box"]
|
box = event.box
|
||||||
|
|
||||||
# provide top score if score is unavailable
|
# provide top score if score is unavailable
|
||||||
score = (
|
score = event.top_score if event.score is None else event.score
|
||||||
(event.data["top_score"] if event.data["top_score"] else event.top_score)
|
|
||||||
if event.data["score"] is None
|
|
||||||
else event.data["score"]
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
current_app.plus_api.add_false_positive(
|
current_app.plus_api.add_false_positive(
|
||||||
@ -759,7 +758,6 @@ def events():
|
|||||||
Event.top_score,
|
Event.top_score,
|
||||||
Event.false_positive,
|
Event.false_positive,
|
||||||
Event.box,
|
Event.box,
|
||||||
Event.data,
|
|
||||||
]
|
]
|
||||||
|
|
||||||
if camera != "all":
|
if camera != "all":
|
||||||
@ -848,6 +846,58 @@ def events():
|
|||||||
return jsonify([model_to_dict(e, exclude=excluded_fields) for e in events])
|
return jsonify([model_to_dict(e, exclude=excluded_fields) for e in events])
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/events/<camera_name>/<label>/create", methods=["POST"])
|
||||||
|
def create_event(camera_name, label):
|
||||||
|
if not camera_name or not current_app.frigate_config.cameras.get(camera_name):
|
||||||
|
return jsonify(
|
||||||
|
{"success": False, "message": f"{camera_name} is not a valid camera."}, 404
|
||||||
|
)
|
||||||
|
|
||||||
|
if not label:
|
||||||
|
return jsonify({"success": False, "message": f"{label} must be set."}, 404)
|
||||||
|
|
||||||
|
json: dict[str, any] = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
frame = current_app.detected_frames_processor.get_current_frame(camera_name)
|
||||||
|
|
||||||
|
event_id = current_app.external_processor.create_manual_event(
|
||||||
|
camera_name,
|
||||||
|
label,
|
||||||
|
json.get("sub_label", None),
|
||||||
|
json.get("duration", 30),
|
||||||
|
json.get("include_recording", True),
|
||||||
|
json.get("draw", {}),
|
||||||
|
frame,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"The error is {e}")
|
||||||
|
return jsonify(
|
||||||
|
{"success": False, "message": f"An unknown error occurred: {e}"}, 404
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"message": "Successfully created event.",
|
||||||
|
"event_id": event_id,
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/events/<event_id>/end", methods=["PUT"])
|
||||||
|
def end_event(event_id):
|
||||||
|
try:
|
||||||
|
current_app.external_processor.finish_manual_event(event_id)
|
||||||
|
except:
|
||||||
|
return jsonify(
|
||||||
|
{"success": False, "message": f"{event_id} must be set and valid."}, 404
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({"success": True, "message": f"Event successfully ended."}, 200)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/config")
|
@bp.route("/config")
|
||||||
def config():
|
def config():
|
||||||
config = current_app.frigate_config.dict()
|
config = current_app.frigate_config.dict()
|
||||||
@ -866,11 +916,6 @@ def config():
|
|||||||
|
|
||||||
config["plus"] = {"enabled": current_app.plus_api.is_active()}
|
config["plus"] = {"enabled": current_app.plus_api.is_active()}
|
||||||
|
|
||||||
for detector, detector_config in config["detectors"].items():
|
|
||||||
detector_config["model"][
|
|
||||||
"labelmap"
|
|
||||||
] = current_app.frigate_config.model.merged_labelmap
|
|
||||||
|
|
||||||
return jsonify(config)
|
return jsonify(config)
|
||||||
|
|
||||||
|
|
||||||
|
@ -21,7 +21,7 @@ from frigate.config import (
|
|||||||
FrigateConfig,
|
FrigateConfig,
|
||||||
)
|
)
|
||||||
from frigate.const import CLIPS_DIR
|
from frigate.const import CLIPS_DIR
|
||||||
from frigate.events import EventTypeEnum
|
from frigate.events.maintainer import EventTypeEnum
|
||||||
from frigate.util import (
|
from frigate.util import (
|
||||||
SharedMemoryFrameManager,
|
SharedMemoryFrameManager,
|
||||||
calculate_region,
|
calculate_region,
|
||||||
|
0
frigate/record/__init__.py
Normal file
0
frigate/record/__init__.py
Normal file
@ -120,6 +120,7 @@ class TestHttp(unittest.TestCase):
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
PlusApi(),
|
PlusApi(),
|
||||||
)
|
)
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
@ -155,6 +156,7 @@ class TestHttp(unittest.TestCase):
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
PlusApi(),
|
PlusApi(),
|
||||||
)
|
)
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
@ -175,6 +177,7 @@ class TestHttp(unittest.TestCase):
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
PlusApi(),
|
PlusApi(),
|
||||||
)
|
)
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
@ -194,6 +197,7 @@ class TestHttp(unittest.TestCase):
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
PlusApi(),
|
PlusApi(),
|
||||||
)
|
)
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
@ -215,6 +219,7 @@ class TestHttp(unittest.TestCase):
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
PlusApi(),
|
PlusApi(),
|
||||||
)
|
)
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
@ -240,6 +245,7 @@ class TestHttp(unittest.TestCase):
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
PlusApi(),
|
PlusApi(),
|
||||||
)
|
)
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
@ -274,6 +280,7 @@ class TestHttp(unittest.TestCase):
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
PlusApi(),
|
PlusApi(),
|
||||||
)
|
)
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
@ -298,6 +305,7 @@ class TestHttp(unittest.TestCase):
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
PlusApi(),
|
PlusApi(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -314,6 +322,7 @@ class TestHttp(unittest.TestCase):
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
PlusApi(),
|
PlusApi(),
|
||||||
)
|
)
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
@ -333,6 +342,7 @@ class TestHttp(unittest.TestCase):
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
PlusApi(),
|
PlusApi(),
|
||||||
)
|
)
|
||||||
mock_stats.return_value = self.test_stats
|
mock_stats.return_value = self.test_stats
|
||||||
|
@ -5,7 +5,7 @@ import threading
|
|||||||
import queue
|
import queue
|
||||||
|
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.events import EventTypeEnum
|
from frigate.events.maintainer import EventTypeEnum
|
||||||
from frigate.models import Timeline
|
from frigate.models import Timeline
|
||||||
|
|
||||||
from multiprocessing.queues import Queue
|
from multiprocessing.queues import Queue
|
||||||
|
@ -599,7 +599,9 @@ export default function Events({ path, ...props }) {
|
|||||||
{event.sub_label
|
{event.sub_label
|
||||||
? `${event.label.replaceAll('_', ' ')}: ${event.sub_label.replaceAll('_', ' ')}`
|
? `${event.label.replaceAll('_', ' ')}: ${event.sub_label.replaceAll('_', ' ')}`
|
||||||
: event.label.replaceAll('_', ' ')}
|
: event.label.replaceAll('_', ' ')}
|
||||||
({((event?.data?.top_score || event.top_score) * 100).toFixed(0)}%)
|
{(event?.data?.top_score || event.top_score || 0) == 0
|
||||||
|
? null
|
||||||
|
: ` (${((event?.data?.top_score || event.top_score) * 100).toFixed(0)}%)`}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm flex">
|
<div className="text-sm flex">
|
||||||
<Clock className="h-5 w-5 mr-2 inline" />
|
<Clock className="h-5 w-5 mr-2 inline" />
|
||||||
|
Loading…
Reference in New Issue
Block a user