FEAT: Ability to set sub labels for specific events (#2949)

* Add sub label to model and set / delete funs

* Add migrations for sub label

* Tweaks to API and model

* Show sublabel if available

* Cleanups

* Update docs

* Show person in UI title

* Fix typo and don't fail on no json

* Transfer sub labels for in progress events

* Remove sublabel reset

* Remove person only check

* Make default null

* Update docs and formatting

* Make default null

* Make nullable in migration

* Undo null

* Update model to accept null

* Update migration to accept null

* Don't set to default values

* Remove redundant defaults and update http logic

* Only need a single route

* Enforce 20 character limit in http

* Update docs to mention 20 character limit

* Cleanup

* Separate insert and update to make sure updated values are retained when event ends

* Use insert instead of replace

* Remove redundant if and have should_update_db include clip or snapshot requirement.
This commit is contained in:
Nicolas Mowen 2022-03-17 06:18:43 -06:00 committed by GitHub
parent 0abd0627df
commit b1cc64d4fa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 144 additions and 33 deletions

View File

@ -186,6 +186,17 @@ Sets retain to true for the event id.
Sets retain to false for the event id (event may be deleted quickly after removing). Sets retain to false for the event id (event may be deleted quickly after removing).
### `POST /api/events/<id>/sub_label`
Set a sub label for an event. For example to update `person` -> `person's name` if they were recognized with facial recognition.
Sub labels must be 20 characters or shorter.
```json
{
"subLabel": "some_string"
}
```
### `GET /api/events/<id>/thumbnail.jpg` ### `GET /api/events/<id>/thumbnail.jpg`
Returns a thumbnail for the event id optimized for notifications. Works while the event is in progress and after completion. Passing `?format=android` will convert the thumbnail to 2:1 aspect ratio. Returns a thumbnail for the event id optimized for notifications. Works while the event is in progress and after completion. Passing `?format=android` will convert the thumbnail to 2:1 aspect ratio.

View File

@ -14,14 +14,22 @@ from frigate.models import Event
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def should_insert_db(prev_event, current_event):
"""If current event has new clip or snapshot."""
return (
(not prev_event["has_clip"] and not prev_event["has_snapshot"])
and (current_event["has_clip"] or current_event["has_snapshot"])
)
def should_update_db(prev_event, current_event): def should_update_db(prev_event, current_event):
"""If current_event has updated fields and (clip or snapshot)."""
return ( return (
prev_event["top_score"] != current_event["top_score"] (current_event["has_clip"] or current_event["has_snapshot"])
or prev_event["entered_zones"] != current_event["entered_zones"] and (prev_event["top_score"] != current_event["top_score"]
or prev_event["thumbnail"] != current_event["thumbnail"] or prev_event["entered_zones"] != current_event["entered_zones"]
or prev_event["has_clip"] != current_event["has_clip"] or prev_event["thumbnail"] != current_event["thumbnail"]
or prev_event["has_snapshot"] != current_event["has_snapshot"] or prev_event["has_clip"] != current_event["has_clip"]
or prev_event["has_snapshot"] != current_event["has_snapshot"])
) )
@ -58,33 +66,52 @@ class EventProcessor(threading.Thread):
if event_type == "start": if event_type == "start":
self.events_in_process[event_data["id"]] = event_data self.events_in_process[event_data["id"]] = event_data
elif event_type == "update" and should_insert_db(
self.events_in_process[event_data["id"]], event_data
):
self.events_in_process[event_data["id"]] = event_data
# TODO: this will generate a lot of db activity possibly
Event.insert(
id=event_data["id"],
label=event_data["label"],
camera=camera,
start_time=event_data["start_time"] - event_config.pre_capture,
end_time=None,
top_score=event_data["top_score"],
false_positive=event_data["false_positive"],
zones=list(event_data["entered_zones"]),
thumbnail=event_data["thumbnail"],
region=event_data["region"],
box=event_data["box"],
area=event_data["area"],
has_clip=event_data["has_clip"],
has_snapshot=event_data["has_snapshot"],
).execute()
elif event_type == "update" and should_update_db( elif event_type == "update" and should_update_db(
self.events_in_process[event_data["id"]], event_data self.events_in_process[event_data["id"]], event_data
): ):
self.events_in_process[event_data["id"]] = event_data self.events_in_process[event_data["id"]] = event_data
# TODO: this will generate a lot of db activity possibly # TODO: this will generate a lot of db activity possibly
if event_data["has_clip"] or event_data["has_snapshot"]: Event.update(
Event.replace( label=event_data["label"],
id=event_data["id"], camera=camera,
label=event_data["label"], start_time=event_data["start_time"] - event_config.pre_capture,
camera=camera, end_time=None,
start_time=event_data["start_time"] - event_config.pre_capture, top_score=event_data["top_score"],
end_time=None, false_positive=event_data["false_positive"],
top_score=event_data["top_score"], zones=list(event_data["entered_zones"]),
false_positive=event_data["false_positive"], thumbnail=event_data["thumbnail"],
zones=list(event_data["entered_zones"]), region=event_data["region"],
thumbnail=event_data["thumbnail"], box=event_data["box"],
region=event_data["region"], area=event_data["area"],
box=event_data["box"], has_clip=event_data["has_clip"],
area=event_data["area"], has_snapshot=event_data["has_snapshot"],
has_clip=event_data["has_clip"], ).where(Event.id == event_data["id"]).execute()
has_snapshot=event_data["has_snapshot"],
).execute()
elif event_type == "end": elif event_type == "end":
if event_data["has_clip"] or event_data["has_snapshot"]: if event_data["has_clip"] or event_data["has_snapshot"]:
Event.replace( Event.update(
id=event_data["id"],
label=event_data["label"], label=event_data["label"],
camera=camera, camera=camera,
start_time=event_data["start_time"] - event_config.pre_capture, start_time=event_data["start_time"] - event_config.pre_capture,
@ -98,7 +125,7 @@ class EventProcessor(threading.Thread):
area=event_data["area"], area=event_data["area"],
has_clip=event_data["has_clip"], has_clip=event_data["has_clip"],
has_snapshot=event_data["has_snapshot"], has_snapshot=event_data["has_snapshot"],
).execute() ).where(Event.id == event_data["id"]).execute()
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))

View File

@ -126,14 +126,14 @@ def set_retain(id):
event = Event.get(Event.id == id) event = Event.get(Event.id == id)
except DoesNotExist: except DoesNotExist:
return make_response( return make_response(
jsonify({"success": False, "message": "Event" + id + " not found"}), 404 jsonify({"success": False, "message": "Event " + id + " not found"}), 404
) )
event.retain_indefinitely = True event.retain_indefinitely = True
event.save() event.save()
return make_response( return make_response(
jsonify({"success": True, "message": "Event" + id + " retained"}), 200 jsonify({"success": True, "message": "Event " + id + " retained"}), 200
) )
@ -143,16 +143,42 @@ def delete_retain(id):
event = Event.get(Event.id == id) event = Event.get(Event.id == id)
except DoesNotExist: except DoesNotExist:
return make_response( return make_response(
jsonify({"success": False, "message": "Event" + id + " not found"}), 404 jsonify({"success": False, "message": "Event " + id + " not found"}), 404
) )
event.retain_indefinitely = False event.retain_indefinitely = False
event.save() event.save()
return make_response( return make_response(
jsonify({"success": True, "message": "Event" + id + " un-retained"}), 200 jsonify({"success": True, "message": "Event " + id + " un-retained"}), 200
) )
@bp.route("/events/<id>/sub_label", methods=("POST",))
def set_sub_label(id):
try:
event = Event.get(Event.id == id)
except DoesNotExist:
return make_response(
jsonify({"success": False, "message": "Event " + id + " not found"}), 404
)
if request.json:
new_sub_label = request.json.get("subLabel")
else:
new_sub_label = None
if new_sub_label and len(new_sub_label) > 20:
return make_response(
jsonify({"success": False, "message": new_sub_label + " exceeds the 20 character limit for sub_label"}), 400
)
event.sub_label = new_sub_label
event.save()
return make_response(
jsonify({"success": True, "message": "Event " + id + " sub label set to " + new_sub_label}), 200
)
@bp.route("/events/<id>", methods=("DELETE",)) @bp.route("/events/<id>", methods=("DELETE",))
def delete_event(id): def delete_event(id):
@ -160,7 +186,7 @@ def delete_event(id):
event = Event.get(Event.id == id) event = Event.get(Event.id == id)
except DoesNotExist: except DoesNotExist:
return make_response( return make_response(
jsonify({"success": False, "message": "Event" + id + " not found"}), 404 jsonify({"success": False, "message": "Event " + id + " not found"}), 404
) )
media_name = f"{event.camera}-{event.id}" media_name = f"{event.camera}-{event.id}"
@ -175,7 +201,7 @@ def delete_event(id):
event.delete_instance() event.delete_instance()
return make_response( return make_response(
jsonify({"success": True, "message": "Event" + id + " deleted"}), 200 jsonify({"success": True, "message": "Event " + id + " deleted"}), 200
) )

View File

@ -6,6 +6,7 @@ from playhouse.sqlite_ext import *
class Event(Model): class Event(Model):
id = CharField(null=False, primary_key=True, max_length=30) id = CharField(null=False, primary_key=True, max_length=30)
label = CharField(index=True, max_length=20) label = CharField(index=True, max_length=20)
sub_label = CharField(max_length=20, null=True)
camera = CharField(index=True, max_length=20) camera = CharField(index=True, max_length=20)
start_time = DateTimeField() start_time = DateTimeField()
end_time = DateTimeField() end_time = DateTimeField()

View File

@ -0,0 +1,46 @@
"""Peewee migrations -- 008_add_sub_label.py.
Some examples (model - class or model name)::
> Model = migrator.orm['model_name'] # Return model in current state by name
> migrator.sql(sql) # Run custom SQL
> migrator.python(func, *args, **kwargs) # Run python code
> migrator.create_model(Model) # Create a model (could be used as decorator)
> migrator.remove_model(model, cascade=True) # Remove a model
> migrator.add_fields(model, **fields) # Add fields to a model
> migrator.change_fields(model, **fields) # Change fields
> migrator.remove_fields(model, *field_names, cascade=True)
> migrator.rename_field(model, old_field_name, new_field_name)
> migrator.rename_table(model, new_table_name)
> migrator.add_index(model, *col_names, unique=False)
> migrator.drop_index(model, *col_names)
> migrator.add_not_null(model, *field_names)
> migrator.drop_not_null(model, *field_names)
> migrator.add_default(model, field_name, default)
"""
import datetime as dt
import peewee as pw
from playhouse.sqlite_ext import *
from decimal import ROUND_HALF_EVEN
from frigate.models import Event
try:
import playhouse.postgres_ext as pw_pext
except ImportError:
pass
SQL = pw.SQL
def migrate(migrator, database, fake=False, **kwargs):
migrator.add_fields(
Event,
sub_label=pw.CharField(max_length=20, null=True),
)
def rollback(migrator, database, fake=False, **kwargs):
migrator.remove_fields(Event, ["sub_label"])

View File

@ -315,7 +315,7 @@ export default function Events({ path, ...props }) {
<div className="m-2 flex grow"> <div className="m-2 flex grow">
<div className="flex flex-col grow"> <div className="flex flex-col grow">
<div className="capitalize text-lg font-bold"> <div className="capitalize text-lg font-bold">
{event.label} ({(event.top_score * 100).toFixed(0)}%) {event.sub_label ? `${event.label}: ${event.sub_label}` : event.label} ({(event.top_score * 100).toFixed(0)}%)
</div> </div>
<div className="text-sm"> <div className="text-sm">
{new Date(event.start_time * 1000).toLocaleDateString()}{' '} {new Date(event.start_time * 1000).toLocaleDateString()}{' '}