Skip to content

Commit

Permalink
Ability to manually create events through the API (blakeblackshear#3184)
Browse files Browse the repository at this point in the history
* 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
NickM-27 and blakeblackshear committed May 19, 2023
1 parent 6d0c2ec commit e357715
Show file tree
Hide file tree
Showing 12 changed files with 466 additions and 180 deletions.
38 changes: 38 additions & 0 deletions docs/docs/integrations/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,3 +295,41 @@ Get ffprobe output for camera feed paths.
### `GET /api/<camera_name>/ptz/info`

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.
11 changes: 10 additions & 1 deletion frigate/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@
RECORD_DIR,
)
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.log import log_process, root_configurer
from frigate.models import Event, Recordings, Timeline
Expand Down Expand Up @@ -204,6 +206,11 @@ def init_stats(self) -> None:
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:
self.flask_app = create_app(
self.config,
Expand All @@ -212,6 +219,7 @@ def init_web_server(self) -> None:
self.detected_frames_processor,
self.storage_maintainer,
self.onvif_controller,
self.external_event_processor,
self.plus_api,
)

Expand Down Expand Up @@ -436,6 +444,7 @@ def start(self) -> None:
self.start_camera_capture_processes()
self.start_storage_maintainer()
self.init_stats()
self.init_external_event_processor()
self.init_web_server()
self.start_timeline_processor()
self.start_event_processor()
Expand Down
Empty file added frigate/events/__init__.py
Empty file.
176 changes: 176 additions & 0 deletions frigate/events/cleanup.py
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...")
132 changes: 132 additions & 0 deletions frigate/events/external.py
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")
Loading

0 comments on commit e357715

Please sign in to comment.