mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +01:00
add endpoint to submit to plus
This commit is contained in:
parent
045aac8933
commit
e724fe3da6
@ -57,7 +57,8 @@ RUN pip3 wheel --wheel-dir=/wheels \
|
|||||||
peewee_migrate \
|
peewee_migrate \
|
||||||
pydantic \
|
pydantic \
|
||||||
zeroconf \
|
zeroconf \
|
||||||
ws4py
|
ws4py \
|
||||||
|
requests
|
||||||
|
|
||||||
# Frigate Container
|
# Frigate Container
|
||||||
FROM debian:11-slim
|
FROM debian:11-slim
|
||||||
|
@ -16,7 +16,7 @@ from playhouse.sqliteq import SqliteQueueDatabase
|
|||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from frigate.config import DetectorTypeEnum, FrigateConfig
|
from frigate.config import DetectorTypeEnum, FrigateConfig
|
||||||
from frigate.const import CACHE_DIR, CLIPS_DIR, RECORD_DIR
|
from frigate.const import CACHE_DIR, CLIPS_DIR, RECORD_DIR, PLUS_ENV_VAR, PLUS_API_HOST
|
||||||
from frigate.edgetpu import EdgeTPUProcess
|
from frigate.edgetpu import EdgeTPUProcess
|
||||||
from frigate.events import EventCleanup, EventProcessor
|
from frigate.events import EventCleanup, EventProcessor
|
||||||
from frigate.http import create_app
|
from frigate.http import create_app
|
||||||
@ -25,6 +25,7 @@ from frigate.models import Event, Recordings
|
|||||||
from frigate.mqtt import MqttSocketRelay, create_mqtt_client
|
from frigate.mqtt import MqttSocketRelay, create_mqtt_client
|
||||||
from frigate.object_processing import TrackedObjectProcessor
|
from frigate.object_processing import TrackedObjectProcessor
|
||||||
from frigate.output import output_frames
|
from frigate.output import output_frames
|
||||||
|
from frigate.plus import PlusApi
|
||||||
from frigate.record import RecordingCleanup, RecordingMaintainer
|
from frigate.record import RecordingCleanup, RecordingMaintainer
|
||||||
from frigate.stats import StatsEmitter, stats_init
|
from frigate.stats import StatsEmitter, stats_init
|
||||||
from frigate.version import VERSION
|
from frigate.version import VERSION
|
||||||
@ -44,6 +45,11 @@ class FrigateApp:
|
|||||||
self.detection_out_events: Dict[str, mp.Event] = {}
|
self.detection_out_events: Dict[str, mp.Event] = {}
|
||||||
self.detection_shms: List[mp.shared_memory.SharedMemory] = []
|
self.detection_shms: List[mp.shared_memory.SharedMemory] = []
|
||||||
self.log_queue = mp.Queue()
|
self.log_queue = mp.Queue()
|
||||||
|
self.plus_api = (
|
||||||
|
PlusApi(PLUS_API_HOST, os.environ.get(PLUS_ENV_VAR))
|
||||||
|
if PLUS_ENV_VAR in os.environ
|
||||||
|
else None
|
||||||
|
)
|
||||||
self.camera_metrics = {}
|
self.camera_metrics = {}
|
||||||
|
|
||||||
def set_environment_vars(self):
|
def set_environment_vars(self):
|
||||||
@ -146,6 +152,7 @@ class FrigateApp:
|
|||||||
self.db,
|
self.db,
|
||||||
self.stats_tracking,
|
self.stats_tracking,
|
||||||
self.detected_frames_processor,
|
self.detected_frames_processor,
|
||||||
|
self.plus_api,
|
||||||
)
|
)
|
||||||
|
|
||||||
def init_mqtt(self):
|
def init_mqtt(self):
|
||||||
|
@ -3,3 +3,5 @@ CLIPS_DIR = f"{BASE_DIR}/clips"
|
|||||||
RECORD_DIR = f"{BASE_DIR}/recordings"
|
RECORD_DIR = f"{BASE_DIR}/recordings"
|
||||||
CACHE_DIR = "/tmp/cache"
|
CACHE_DIR = "/tmp/cache"
|
||||||
YAML_EXT = (".yaml", ".yml")
|
YAML_EXT = (".yaml", ".yml")
|
||||||
|
PLUS_ENV_VAR = "PLUS_API_KEY"
|
||||||
|
PLUS_API_HOST = "https://api.frigate.video"
|
||||||
|
@ -29,7 +29,7 @@ from flask import (
|
|||||||
from peewee import SqliteDatabase, operator, fn, DoesNotExist, Value
|
from peewee import SqliteDatabase, operator, fn, DoesNotExist, Value
|
||||||
from playhouse.shortcuts import model_to_dict
|
from playhouse.shortcuts import model_to_dict
|
||||||
|
|
||||||
from frigate.const import CLIPS_DIR, RECORD_DIR
|
from frigate.const import CLIPS_DIR, PLUS_ENV_VAR
|
||||||
from frigate.models import Event, Recordings
|
from frigate.models import Event, Recordings
|
||||||
from frigate.stats import stats_snapshot
|
from frigate.stats import stats_snapshot
|
||||||
from frigate.util import calculate_region
|
from frigate.util import calculate_region
|
||||||
@ -45,6 +45,7 @@ def create_app(
|
|||||||
database: SqliteDatabase,
|
database: SqliteDatabase,
|
||||||
stats_tracking,
|
stats_tracking,
|
||||||
detected_frames_processor,
|
detected_frames_processor,
|
||||||
|
plus_api,
|
||||||
):
|
):
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
@ -61,6 +62,7 @@ def create_app(
|
|||||||
app.frigate_config = frigate_config
|
app.frigate_config = frigate_config
|
||||||
app.stats_tracking = stats_tracking
|
app.stats_tracking = stats_tracking
|
||||||
app.detected_frames_processor = detected_frames_processor
|
app.detected_frames_processor = detected_frames_processor
|
||||||
|
app.plus_api = plus_api
|
||||||
|
|
||||||
app.register_blueprint(bp)
|
app.register_blueprint(bp)
|
||||||
|
|
||||||
@ -137,6 +139,52 @@ def set_retain(id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/events/<id>/plus", methods=("POST",))
|
||||||
|
def send_to_plus(id):
|
||||||
|
if current_app.plus_api is None:
|
||||||
|
return make_response(
|
||||||
|
jsonify({"success": False, "message": "Plus token not set"}), 400
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
event = Event.get(Event.id == id)
|
||||||
|
except DoesNotExist:
|
||||||
|
return make_response(
|
||||||
|
jsonify({"success": False, "message": "Event" + id + " not found"}), 404
|
||||||
|
)
|
||||||
|
|
||||||
|
if event.plus_id:
|
||||||
|
return make_response(
|
||||||
|
jsonify({"success": False, "message": "Already submitted to plus"}), 400
|
||||||
|
)
|
||||||
|
|
||||||
|
# load clean.png
|
||||||
|
try:
|
||||||
|
filename = f"{event.camera}-{event.id}-clean.png"
|
||||||
|
image = cv2.imread(os.path.join(CLIPS_DIR, filename))
|
||||||
|
except Exception:
|
||||||
|
return make_response(
|
||||||
|
jsonify(
|
||||||
|
{"success": False, "message": "Unable to load clean png for event"}
|
||||||
|
),
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
plus_id = current_app.plus_api.upload_image(image, event.camera)
|
||||||
|
except Exception as ex:
|
||||||
|
return make_response(
|
||||||
|
jsonify({"success": False, "message": str(ex)}),
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# store image id in the database
|
||||||
|
event.plus_id = plus_id
|
||||||
|
event.save()
|
||||||
|
|
||||||
|
return "success"
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/events/<id>/retain", methods=("DELETE",))
|
@bp.route("/events/<id>/retain", methods=("DELETE",))
|
||||||
def delete_retain(id):
|
def delete_retain(id):
|
||||||
try:
|
try:
|
||||||
@ -252,6 +300,7 @@ def event_thumbnail(id):
|
|||||||
response.headers["Cache-Control"] = "private, max-age=31536000"
|
response.headers["Cache-Control"] = "private, max-age=31536000"
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/<camera_name>/<label>/best.jpg")
|
@bp.route("/<camera_name>/<label>/best.jpg")
|
||||||
@bp.route("/<camera_name>/<label>/thumbnail.jpg")
|
@bp.route("/<camera_name>/<label>/thumbnail.jpg")
|
||||||
def label_thumbnail(camera_name, label):
|
def label_thumbnail(camera_name, label):
|
||||||
@ -277,9 +326,7 @@ def label_thumbnail(camera_name, label):
|
|||||||
return event_thumbnail(event.id)
|
return event_thumbnail(event.id)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
frame = np.zeros((175, 175, 3), np.uint8)
|
frame = np.zeros((175, 175, 3), np.uint8)
|
||||||
ret, jpg = cv2.imencode(
|
ret, jpg = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), 70])
|
||||||
".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), 70]
|
|
||||||
)
|
|
||||||
|
|
||||||
response = make_response(jpg.tobytes())
|
response = make_response(jpg.tobytes())
|
||||||
response.headers["Content-Type"] = "image/jpeg"
|
response.headers["Content-Type"] = "image/jpeg"
|
||||||
@ -330,6 +377,7 @@ def event_snapshot(id):
|
|||||||
] = f"attachment; filename=snapshot-{id}.jpg"
|
] = f"attachment; filename=snapshot-{id}.jpg"
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/<camera_name>/<label>/snapshot.jpg")
|
@bp.route("/<camera_name>/<label>/snapshot.jpg")
|
||||||
def label_snapshot(camera_name, label):
|
def label_snapshot(camera_name, label):
|
||||||
if label == "any":
|
if label == "any":
|
||||||
@ -353,9 +401,7 @@ def label_snapshot(camera_name, label):
|
|||||||
return event_snapshot(event.id)
|
return event_snapshot(event.id)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
frame = np.zeros((720, 1280, 3), np.uint8)
|
frame = np.zeros((720, 1280, 3), np.uint8)
|
||||||
ret, jpg = cv2.imencode(
|
ret, jpg = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), 70])
|
||||||
".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), 70]
|
|
||||||
)
|
|
||||||
|
|
||||||
response = make_response(jpg.tobytes())
|
response = make_response(jpg.tobytes())
|
||||||
response.headers["Content-Type"] = "image/jpeg"
|
response.headers["Content-Type"] = "image/jpeg"
|
||||||
@ -460,6 +506,8 @@ def config():
|
|||||||
for cmd in camera_dict["ffmpeg_cmds"]:
|
for cmd in camera_dict["ffmpeg_cmds"]:
|
||||||
cmd["cmd"] = " ".join(cmd["cmd"])
|
cmd["cmd"] = " ".join(cmd["cmd"])
|
||||||
|
|
||||||
|
config["plus"] = {"enabled": PLUS_ENV_VAR in os.environ}
|
||||||
|
|
||||||
return jsonify(config)
|
return jsonify(config)
|
||||||
|
|
||||||
|
|
||||||
|
@ -21,6 +21,7 @@ class Event(Model):
|
|||||||
area = IntegerField()
|
area = IntegerField()
|
||||||
retain_indefinitely = BooleanField(default=False)
|
retain_indefinitely = BooleanField(default=False)
|
||||||
ratio = FloatField(default=1.0)
|
ratio = FloatField(default=1.0)
|
||||||
|
plus_id = CharField(max_length=30)
|
||||||
|
|
||||||
|
|
||||||
class Recordings(Model):
|
class Recordings(Model):
|
||||||
|
96
frigate/plus.py
Normal file
96
frigate/plus.py
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_jpg_bytes(image, max_dim, quality):
|
||||||
|
if image.shape[1] >= image.shape[0]:
|
||||||
|
width = min(max_dim, image.shape[1])
|
||||||
|
height = int(width * image.shape[0] / image.shape[1])
|
||||||
|
else:
|
||||||
|
height = min(max_dim, image.shape[0])
|
||||||
|
width = int(height * image.shape[1] / image.shape[0])
|
||||||
|
|
||||||
|
original = cv2.resize(image, dsize=(width, height), interpolation=cv2.INTER_AREA)
|
||||||
|
|
||||||
|
ret, jpg = cv2.imencode(".jpg", original, [int(cv2.IMWRITE_JPEG_QUALITY), quality])
|
||||||
|
return jpg.tobytes()
|
||||||
|
|
||||||
|
|
||||||
|
class PlusApi:
|
||||||
|
def __init__(self, host, key: str):
|
||||||
|
self.host = host
|
||||||
|
self.key = key
|
||||||
|
self._token_data = None
|
||||||
|
|
||||||
|
def _refresh_token_if_needed(self):
|
||||||
|
if (
|
||||||
|
self._token_data is None
|
||||||
|
or self._token_data["expires"] - datetime.datetime.now().timestamp() < 60
|
||||||
|
):
|
||||||
|
parts = self.key.split(":")
|
||||||
|
r = requests.get(f"{self.host}/v1/auth/token", auth=(parts[0], parts[1]))
|
||||||
|
self._token_data = r.json()
|
||||||
|
|
||||||
|
def _get_authorization_header(self):
|
||||||
|
self._refresh_token_if_needed()
|
||||||
|
return {"authorization": f"Bearer {self._token_data['accessToken']}"}
|
||||||
|
|
||||||
|
def _get(self, path):
|
||||||
|
return requests.get(
|
||||||
|
f"{self.host}/v1/{path}", headers=self._get_authorization_header()
|
||||||
|
)
|
||||||
|
|
||||||
|
def _post(self, path, data):
|
||||||
|
return requests.post(
|
||||||
|
f"{self.host}/v1/{path}",
|
||||||
|
headers=self._get_authorization_header(),
|
||||||
|
json=data,
|
||||||
|
)
|
||||||
|
|
||||||
|
def upload_image(self, image, camera: str):
|
||||||
|
r = self._get("image/signed_urls")
|
||||||
|
presigned_urls = r.json()
|
||||||
|
if not r.ok:
|
||||||
|
logger.exception(ex)
|
||||||
|
raise Exception("Unable to get signed urls")
|
||||||
|
|
||||||
|
# resize and submit original
|
||||||
|
files = {"file": get_jpg_bytes(image, 1920, 85)}
|
||||||
|
data = presigned_urls["original"]["fields"]
|
||||||
|
data["content-type"] = "image/jpeg"
|
||||||
|
r = requests.post(presigned_urls["original"]["url"], files=files, data=data)
|
||||||
|
if not r.ok:
|
||||||
|
logger.error(f"Failed to upload original: {r.status_code} {r.text}")
|
||||||
|
raise Exception(r.text)
|
||||||
|
|
||||||
|
# resize and submit annotate
|
||||||
|
files = {"file": get_jpg_bytes(image, 640, 70)}
|
||||||
|
data = presigned_urls["annotate"]["fields"]
|
||||||
|
data["content-type"] = "image/jpeg"
|
||||||
|
r = requests.post(presigned_urls["annotate"]["url"], files=files, data=data)
|
||||||
|
if not r.ok:
|
||||||
|
logger.error(f"Failed to upload annotate: {r.status_code} {r.text}")
|
||||||
|
raise Exception(r.text)
|
||||||
|
|
||||||
|
# resize and submit thumbnail
|
||||||
|
files = {"file": get_jpg_bytes(image, 200, 70)}
|
||||||
|
data = presigned_urls["thumbnail"]["fields"]
|
||||||
|
data["content-type"] = "image/jpeg"
|
||||||
|
r = requests.post(presigned_urls["thumbnail"]["url"], files=files, data=data)
|
||||||
|
if not r.ok:
|
||||||
|
logger.error(f"Failed to upload thumbnail: {r.status_code} {r.text}")
|
||||||
|
raise Exception(r.text)
|
||||||
|
|
||||||
|
# create image
|
||||||
|
r = self._post(
|
||||||
|
"image/create", {"id": presigned_urls["imageId"], "camera": camera}
|
||||||
|
)
|
||||||
|
if not r.ok:
|
||||||
|
raise Exception(r.text)
|
||||||
|
|
||||||
|
# return image id
|
||||||
|
return presigned_urls["imageId"]
|
46
migrations/010_add_plus_image_id.py
Normal file
46
migrations/010_add_plus_image_id.py
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
"""Peewee migrations -- 010_add_plus_image_id.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,
|
||||||
|
plus_id=pw.CharField(max_length=30, null=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def rollback(migrator, database, fake=False, **kwargs):
|
||||||
|
migrator.remove_fields(Event, ["plus_id"])
|
Loading…
Reference in New Issue
Block a user