mirror of
				https://github.com/blakeblackshear/frigate.git
				synced 2025-10-27 10:52:11 +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