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 /api/<camera_name>/ptz/info`
Get PTZ info for the camera. 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, 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.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

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 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
@ -196,161 +195,3 @@ class EventProcessor(threading.Thread):
if event_type == "end": if event_type == "end":
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))
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 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_manual import create_manual_event, finish_manual_event
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
@ -44,6 +45,7 @@ from frigate.util import (
restart_frigate, restart_frigate,
vainfo_hwaccel, vainfo_hwaccel,
get_tz_modifiers, get_tz_modifiers,
to_relative_box,
) )
from frigate.storage import StorageMaintainer from frigate.storage import StorageMaintainer
from frigate.version import VERSION from frigate.version import VERSION
@ -195,7 +197,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 +253,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 +295,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 +312,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 +757,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 +845,47 @@ 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/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") @bp.route("/config")
def config(): def config():
config = current_app.frigate_config.dict() config = current_app.frigate_config.dict()
@ -866,11 +904,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)

View File

@ -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,

View File

@ -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