Move to events package

This commit is contained in:
Nick Mowen 2023-05-01 07:29:12 -06:00
parent 6d0c2ec5c8
commit 27f01d1dca
8 changed files with 383 additions and 180 deletions

View File

@ -295,3 +295,12 @@ Get ffprobe output for camera feed paths.
### `GET /api/<camera_name>/ptz/info`
Get PTZ info for the camera.
### `POST /api/events/manual/<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.
NOTE: This call will return the unique ID for the event which will be required to `end` the event.
### `POST /api/events/manual/<event_id>/end`
End a specific manual event.

View File

@ -28,7 +28,8 @@ from frigate.const import (
RECORD_DIR,
)
from frigate.object_detection import ObjectDetectProcess
from frigate.events import EventCleanup, EventProcessor
from frigate.events.cleanup import EventCleanup
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

175
frigate/events/cleanup.py Normal file
View File

@ -0,0 +1,175 @@
"""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...")

View File

@ -10,7 +10,6 @@ from pathlib import Path
from peewee import fn
from frigate.config import EventsConfig, FrigateConfig
from frigate.const import CLIPS_DIR
from frigate.models import Event
from frigate.types import CameraMetricsTypes
from frigate.util import to_relative_box
@ -196,161 +195,3 @@ class EventProcessor(threading.Thread):
if event_type == "end":
del self.events_in_process[event_data["id"]]
self.event_processed_queue.put((event_data["id"], camera))
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...")

144
frigate/events_manual.py Normal file
View File

@ -0,0 +1,144 @@
import base64
import logging
import os
import random
import string
import cv2
from frigate.config import CameraConfig
from frigate.const import CLIPS_DIR
from frigate.models import Event
from frigate.object_processing import TrackedObjectProcessor
logger = logging.getLogger(__name__)
def create_manual_event(
tracked_object_processor: TrackedObjectProcessor,
camera_config: CameraConfig,
camera_name: str,
label: str,
) -> str:
# get a valid frame time for camera
frame_time = tracked_object_processor.get_current_frame_time(camera_name)
# create event id and start frame time
rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
event_id = f"{frame_time}-{rand_id}"
# fabricate obj_data
obj_data = {
"id": event_id,
"camera": camera_name,
"frame_time": frame_time,
"snapshot_time": 0.0,
"label": label,
"top_score": 1,
"false_positive": False,
"start_time": frame_time,
"end_time": None,
"score": 1,
"box": [],
"area": 0,
"ratio": 0,
"region": [],
"stationary": False,
"motionless_count": 0,
"position_changes": [],
"current_zones": "",
"entered_zones": "",
"has_clip": False,
"has_snapshot": False,
"thumbnail": None,
}
# insert object into the queue
current_obj_data = obj_data.copy()
tracked_object_processor.event_queue.put(("start", camera_name, current_obj_data))
# update object data an send another event
obj_data["has_clip"] = camera_config.record.enabled
obj_data["has_snapshot"] = True
# Get current frame for thumb & snapshot
(
current_frame,
updated_frame_time,
) = tracked_object_processor.get_current_frame_and_time(camera_name)
# write jpg snapshot
height = int(current_frame.shape[0])
width = int(height * current_frame.shape[1] / current_frame.shape[0])
current_frame = cv2.resize(
current_frame, dsize=(width, height), interpolation=cv2.INTER_AREA
)
ret, jpg = cv2.imencode(".jpg", current_frame, [int(cv2.IMWRITE_JPEG_QUALITY), 70])
with open(
os.path.join(CLIPS_DIR, f"{camera_name}-{event_id}.jpg"),
"wb",
) as j:
j.write(jpg.tobytes())
# write clean snapshot if enabled
if camera_config.snapshots.clean_copy:
ret, png = cv2.imencode(".png", current_frame)
png_bytes = png.tobytes()
if png_bytes is None:
logger.warning(f"Unable to save clean snapshot for {event_id}.")
else:
with open(
os.path.join(
CLIPS_DIR,
f"{camera_name}-{event_id}-clean.png",
),
"wb",
) as p:
p.write(png_bytes)
# get thumbnail
height = 175
width = int(height * current_frame.shape[1] / current_frame.shape[0])
current_frame = cv2.resize(
current_frame, dsize=(width, height), interpolation=cv2.INTER_AREA
)
ret, thumb = cv2.imencode(
".jpg", current_frame, [int(cv2.IMWRITE_JPEG_QUALITY), 30]
)
obj_data["thumbnail"] = base64.b64encode(thumb).decode("utf-8")
obj_data["snapshot_time"] = updated_frame_time
# update event in queue
tracked_object_processor.event_queue.put(("update", camera_name, obj_data))
return event_id
def finish_manual_event(
tracked_object_processor: TrackedObjectProcessor,
event_id: str,
):
# get associated event and data
manual_event: Event = Event.get(Event.id == event_id)
camera_name = manual_event.camera
# get frame time
frame_time = tracked_object_processor.get_current_frame_time(camera_name)
# create obj_data
obj_data = {
"id": event_id,
"end_time": frame_time,
"has_snapshot": False,
"has_clip": False,
}
# end event in queue
tracked_object_processor.event_queue.put(("end", camera_name, obj_data))

View File

@ -1,8 +1,8 @@
import base64
from datetime import datetime, timedelta, timezone
import copy
import glob
import logging
import glob
import json
import os
import subprocess as sp
@ -34,6 +34,7 @@ from playhouse.shortcuts import model_to_dict
from frigate.config import FrigateConfig
from frigate.const import CLIPS_DIR, MAX_SEGMENT_DURATION, RECORD_DIR
from frigate.models import Event, Recordings, Timeline
from frigate.events_manual import create_manual_event, finish_manual_event
from frigate.object_processing import TrackedObject
from frigate.plus import PlusApi
from frigate.ptz import OnvifController
@ -44,6 +45,7 @@ from frigate.util import (
restart_frigate,
vainfo_hwaccel,
get_tz_modifiers,
to_relative_box,
)
from frigate.storage import StorageMaintainer
from frigate.version import VERSION
@ -195,7 +197,7 @@ def send_to_plus(id):
return make_response(jsonify({"success": False, "message": message}), 404)
# 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
if event.end_time is None:
@ -251,8 +253,8 @@ def send_to_plus(id):
event.save()
if not include_annotation is None:
region = event.data["region"]
box = event.data["box"]
region = event.region
box = event.box
try:
current_app.plus_api.add_annotation(
@ -293,7 +295,7 @@ def false_positive(id):
return make_response(jsonify({"success": False, "message": message}), 404)
# 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"
logger.error(message)
return make_response(jsonify({"success": False, "message": message}), 400)
@ -310,15 +312,11 @@ def false_positive(id):
# need to refetch the event now that it has a plus_id
event = Event.get(Event.id == id)
region = event.data["region"]
box = event.data["box"]
region = event.region
box = event.box
# provide top score if score is unavailable
score = (
(event.data["top_score"] if event.data["top_score"] else event.top_score)
if event.data["score"] is None
else event.data["score"]
)
score = event.top_score if event.score is None else event.score
try:
current_app.plus_api.add_false_positive(
@ -759,7 +757,6 @@ def events():
Event.top_score,
Event.false_positive,
Event.box,
Event.data,
]
if camera != "all":
@ -848,6 +845,47 @@ def events():
return jsonify([model_to_dict(e, exclude=excluded_fields) for e in events])
@bp.route("/events/manual/<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
)
camera_config = current_app.frigate_config.cameras.get(camera_name)
if not label:
return jsonify({"success": False, "message": f"{label} must be set."}, 404)
event_id = create_manual_event(
current_app.detected_frames_processor,
camera_config,
camera_name,
label,
)
return jsonify(
{
"success": True,
"message": f"Event successfully created.",
"event_id": event_id,
},
200,
)
@bp.route("/events/manual/<event_id>/end", methods=("POST",))
def end_event(event_id):
try:
finish_manual_event(current_app.detected_frames_processor, 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")
def config():
config = current_app.frigate_config.dict()
@ -866,11 +904,6 @@ def config():
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)

View File

@ -21,7 +21,7 @@ from frigate.config import (
FrigateConfig,
)
from frigate.const import CLIPS_DIR
from frigate.events import EventTypeEnum
from frigate.events.maintainer import EventTypeEnum
from frigate.util import (
SharedMemoryFrameManager,
calculate_region,

View File

@ -5,7 +5,7 @@ import threading
import queue
from frigate.config import FrigateConfig
from frigate.events import EventTypeEnum
from frigate.events.maintainer import EventTypeEnum
from frigate.models import Timeline
from multiprocessing.queues import Queue