-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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 <[email protected]> * Cleanup docs wording Co-authored-by: Blake Blackshear <[email protected]> * Store full frame for thumbnail * Formatting * Set thumbnail height to 175 * Formatting --------- Co-authored-by: Blake Blackshear <[email protected]>
- Loading branch information
1 parent
6d0c2ec
commit e357715
Showing
12 changed files
with
466 additions
and
180 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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...") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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") |
Oops, something went wrong.