http.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. import base64
  2. import datetime
  3. import json
  4. import logging
  5. import os
  6. import time
  7. from functools import reduce
  8. import cv2
  9. import gevent
  10. import numpy as np
  11. from flask import (Blueprint, Flask, Response, current_app, jsonify,
  12. make_response, request)
  13. from flask_sockets import Sockets
  14. from peewee import SqliteDatabase, operator, fn, DoesNotExist
  15. from playhouse.shortcuts import model_to_dict
  16. from frigate.const import CLIPS_DIR
  17. from frigate.models import Event
  18. from frigate.stats import stats_snapshot
  19. from frigate.util import calculate_region
  20. from frigate.version import VERSION
  21. logger = logging.getLogger(__name__)
  22. bp = Blueprint('frigate', __name__)
  23. ws = Blueprint('ws', __name__)
  24. class MqttBackend():
  25. """Interface for registering and updating WebSocket clients."""
  26. def __init__(self, mqtt_client, topic_prefix):
  27. self.clients = list()
  28. self.mqtt_client = mqtt_client
  29. self.topic_prefix = topic_prefix
  30. def register(self, client):
  31. """Register a WebSocket connection for Mqtt updates."""
  32. self.clients.append(client)
  33. def publish(self, message):
  34. try:
  35. json_message = json.loads(message)
  36. json_message = {
  37. 'topic': f"{self.topic_prefix}/{json_message['topic']}",
  38. 'payload': json_message['payload'],
  39. 'retain': json_message.get('retain', False)
  40. }
  41. except:
  42. logger.warning("Unable to parse websocket message as valid json.")
  43. return
  44. logger.debug(f"Publishing mqtt message from websockets at {json_message['topic']}.")
  45. self.mqtt_client.publish(json_message['topic'], json_message['payload'], retain=json_message['retain'])
  46. def run(self):
  47. def send(client, userdata, message):
  48. """Sends mqtt messages to clients."""
  49. try:
  50. logger.debug(f"Received mqtt message on {message.topic}.")
  51. ws_message = json.dumps({
  52. 'topic': message.topic.replace(f"{self.topic_prefix}/",""),
  53. 'payload': message.payload.decode()
  54. })
  55. except:
  56. # if the payload can't be decoded don't relay to clients
  57. logger.debug(f"MQTT payload for {message.topic} wasn't text. Skipping...")
  58. return
  59. for client in self.clients:
  60. try:
  61. client.send(ws_message)
  62. except:
  63. logger.debug("Removing websocket client due to a closed connection.")
  64. self.clients.remove(client)
  65. self.mqtt_client.message_callback_add(f"{self.topic_prefix}/#", send)
  66. def start(self):
  67. """Maintains mqtt subscription in the background."""
  68. gevent.spawn(self.run)
  69. def create_app(frigate_config, database: SqliteDatabase, stats_tracking, detected_frames_processor, mqtt_client):
  70. app = Flask(__name__)
  71. sockets = Sockets(app)
  72. @app.before_request
  73. def _db_connect():
  74. database.connect()
  75. @app.teardown_request
  76. def _db_close(exc):
  77. if not database.is_closed():
  78. database.close()
  79. app.frigate_config = frigate_config
  80. app.stats_tracking = stats_tracking
  81. app.detected_frames_processor = detected_frames_processor
  82. app.register_blueprint(bp)
  83. sockets.register_blueprint(ws)
  84. app.mqtt_backend = MqttBackend(mqtt_client, frigate_config.mqtt.topic_prefix)
  85. app.mqtt_backend.start()
  86. return app
  87. @bp.route('/')
  88. def is_healthy():
  89. return "Frigate is running. Alive and healthy!"
  90. @bp.route('/events/summary')
  91. def events_summary():
  92. has_clip = request.args.get('has_clip', type=int)
  93. has_snapshot = request.args.get('has_snapshot', type=int)
  94. clauses = []
  95. if not has_clip is None:
  96. clauses.append((Event.has_clip == has_clip))
  97. if not has_snapshot is None:
  98. clauses.append((Event.has_snapshot == has_snapshot))
  99. if len(clauses) == 0:
  100. clauses.append((1 == 1))
  101. groups = (
  102. Event
  103. .select(
  104. Event.camera,
  105. Event.label,
  106. fn.strftime('%Y-%m-%d', fn.datetime(Event.start_time, 'unixepoch', 'localtime')).alias('day'),
  107. Event.zones,
  108. fn.COUNT(Event.id).alias('count')
  109. )
  110. .where(reduce(operator.and_, clauses))
  111. .group_by(
  112. Event.camera,
  113. Event.label,
  114. fn.strftime('%Y-%m-%d', fn.datetime(Event.start_time, 'unixepoch', 'localtime')),
  115. Event.zones
  116. )
  117. )
  118. return jsonify([e for e in groups.dicts()])
  119. @bp.route('/events/<id>')
  120. def event(id):
  121. try:
  122. return model_to_dict(Event.get(Event.id == id))
  123. except DoesNotExist:
  124. return "Event not found", 404
  125. @bp.route('/events/<id>/thumbnail.jpg')
  126. def event_thumbnail(id):
  127. format = request.args.get('format', 'ios')
  128. thumbnail_bytes = None
  129. try:
  130. event = Event.get(Event.id == id)
  131. thumbnail_bytes = base64.b64decode(event.thumbnail)
  132. except DoesNotExist:
  133. # see if the object is currently being tracked
  134. try:
  135. for camera_state in current_app.detected_frames_processor.camera_states.values():
  136. if id in camera_state.tracked_objects:
  137. tracked_obj = camera_state.tracked_objects.get(id)
  138. if not tracked_obj is None:
  139. thumbnail_bytes = tracked_obj.get_thumbnail()
  140. except:
  141. return "Event not found", 404
  142. if thumbnail_bytes is None:
  143. return "Event not found", 404
  144. # android notifications prefer a 2:1 ratio
  145. if format == 'android':
  146. jpg_as_np = np.frombuffer(thumbnail_bytes, dtype=np.uint8)
  147. img = cv2.imdecode(jpg_as_np, flags=1)
  148. thumbnail = cv2.copyMakeBorder(img, 0, 0, int(img.shape[1]*0.5), int(img.shape[1]*0.5), cv2.BORDER_CONSTANT, (0,0,0))
  149. ret, jpg = cv2.imencode('.jpg', thumbnail, [int(cv2.IMWRITE_JPEG_QUALITY), 70])
  150. thumbnail_bytes = jpg.tobytes()
  151. response = make_response(thumbnail_bytes)
  152. response.headers['Content-Type'] = 'image/jpg'
  153. return response
  154. @bp.route('/events/<id>/snapshot.jpg')
  155. def event_snapshot(id):
  156. jpg_bytes = None
  157. try:
  158. event = Event.get(Event.id == id)
  159. if not event.has_snapshot:
  160. return "Snapshot not available", 404
  161. # read snapshot from disk
  162. with open(os.path.join(CLIPS_DIR, f"{event.camera}-{id}.jpg"), 'rb') as image_file:
  163. jpg_bytes = image_file.read()
  164. except DoesNotExist:
  165. # see if the object is currently being tracked
  166. try:
  167. for camera_state in current_app.detected_frames_processor.camera_states.values():
  168. if id in camera_state.tracked_objects:
  169. tracked_obj = camera_state.tracked_objects.get(id)
  170. if not tracked_obj is None:
  171. jpg_bytes = tracked_obj.get_jpg_bytes(
  172. timestamp=request.args.get('timestamp', type=int),
  173. bounding_box=request.args.get('bbox', type=int),
  174. crop=request.args.get('crop', type=int),
  175. height=request.args.get('h', type=int)
  176. )
  177. except:
  178. return "Event not found", 404
  179. except:
  180. return "Event not found", 404
  181. response = make_response(jpg_bytes)
  182. response.headers['Content-Type'] = 'image/jpg'
  183. return response
  184. @bp.route('/events')
  185. def events():
  186. limit = request.args.get('limit', 100)
  187. camera = request.args.get('camera')
  188. label = request.args.get('label')
  189. zone = request.args.get('zone')
  190. after = request.args.get('after', type=float)
  191. before = request.args.get('before', type=float)
  192. has_clip = request.args.get('has_clip', type=int)
  193. has_snapshot = request.args.get('has_snapshot', type=int)
  194. include_thumbnails = request.args.get('include_thumbnails', default=1, type=int)
  195. clauses = []
  196. excluded_fields = []
  197. if camera:
  198. clauses.append((Event.camera == camera))
  199. if label:
  200. clauses.append((Event.label == label))
  201. if zone:
  202. clauses.append((Event.zones.cast('text') % f"*\"{zone}\"*"))
  203. if after:
  204. clauses.append((Event.start_time >= after))
  205. if before:
  206. clauses.append((Event.start_time <= before))
  207. if not has_clip is None:
  208. clauses.append((Event.has_clip == has_clip))
  209. if not has_snapshot is None:
  210. clauses.append((Event.has_snapshot == has_snapshot))
  211. if not include_thumbnails:
  212. excluded_fields.append(Event.thumbnail)
  213. if len(clauses) == 0:
  214. clauses.append((1 == 1))
  215. events = (Event.select()
  216. .where(reduce(operator.and_, clauses))
  217. .order_by(Event.start_time.desc())
  218. .limit(limit))
  219. return jsonify([model_to_dict(e, exclude=excluded_fields) for e in events])
  220. @bp.route('/config')
  221. def config():
  222. return jsonify(current_app.frigate_config.to_dict())
  223. @bp.route('/version')
  224. def version():
  225. return VERSION
  226. @bp.route('/stats')
  227. def stats():
  228. stats = stats_snapshot(current_app.stats_tracking)
  229. return jsonify(stats)
  230. @bp.route('/<camera_name>/<label>/best.jpg')
  231. def best(camera_name, label):
  232. if camera_name in current_app.frigate_config.cameras:
  233. best_object = current_app.detected_frames_processor.get_best(camera_name, label)
  234. best_frame = best_object.get('frame')
  235. if best_frame is None:
  236. best_frame = np.zeros((720,1280,3), np.uint8)
  237. else:
  238. best_frame = cv2.cvtColor(best_frame, cv2.COLOR_YUV2BGR_I420)
  239. crop = bool(request.args.get('crop', 0, type=int))
  240. if crop:
  241. box = best_object.get('box', (0,0,300,300))
  242. region = calculate_region(best_frame.shape, box[0], box[1], box[2], box[3], 1.1)
  243. best_frame = best_frame[region[1]:region[3], region[0]:region[2]]
  244. height = int(request.args.get('h', str(best_frame.shape[0])))
  245. width = int(height*best_frame.shape[1]/best_frame.shape[0])
  246. best_frame = cv2.resize(best_frame, dsize=(width, height), interpolation=cv2.INTER_AREA)
  247. ret, jpg = cv2.imencode('.jpg', best_frame, [int(cv2.IMWRITE_JPEG_QUALITY), 70])
  248. response = make_response(jpg.tobytes())
  249. response.headers['Content-Type'] = 'image/jpg'
  250. return response
  251. else:
  252. return "Camera named {} not found".format(camera_name), 404
  253. @bp.route('/<camera_name>')
  254. def mjpeg_feed(camera_name):
  255. fps = int(request.args.get('fps', '3'))
  256. height = int(request.args.get('h', '360'))
  257. draw_options = {
  258. 'bounding_boxes': request.args.get('bbox', type=int),
  259. 'timestamp': request.args.get('timestamp', type=int),
  260. 'zones': request.args.get('zones', type=int),
  261. 'mask': request.args.get('mask', type=int),
  262. 'motion_boxes': request.args.get('motion', type=int),
  263. 'regions': request.args.get('regions', type=int),
  264. }
  265. if camera_name in current_app.frigate_config.cameras:
  266. # return a multipart response
  267. return Response(imagestream(current_app.detected_frames_processor, camera_name, fps, height, draw_options),
  268. mimetype='multipart/x-mixed-replace; boundary=frame')
  269. else:
  270. return "Camera named {} not found".format(camera_name), 404
  271. @bp.route('/<camera_name>/latest.jpg')
  272. def latest_frame(camera_name):
  273. draw_options = {
  274. 'bounding_boxes': request.args.get('bbox', type=int),
  275. 'timestamp': request.args.get('timestamp', type=int),
  276. 'zones': request.args.get('zones', type=int),
  277. 'mask': request.args.get('mask', type=int),
  278. 'motion_boxes': request.args.get('motion', type=int),
  279. 'regions': request.args.get('regions', type=int),
  280. }
  281. if camera_name in current_app.frigate_config.cameras:
  282. # max out at specified FPS
  283. frame = current_app.detected_frames_processor.get_current_frame(camera_name, draw_options)
  284. if frame is None:
  285. frame = np.zeros((720,1280,3), np.uint8)
  286. height = int(request.args.get('h', str(frame.shape[0])))
  287. width = int(height*frame.shape[1]/frame.shape[0])
  288. frame = cv2.resize(frame, dsize=(width, height), interpolation=cv2.INTER_AREA)
  289. ret, jpg = cv2.imencode('.jpg', frame, [int(cv2.IMWRITE_JPEG_QUALITY), 70])
  290. response = make_response(jpg.tobytes())
  291. response.headers['Content-Type'] = 'image/jpg'
  292. return response
  293. else:
  294. return "Camera named {} not found".format(camera_name), 404
  295. def imagestream(detected_frames_processor, camera_name, fps, height, draw_options):
  296. while True:
  297. # max out at specified FPS
  298. time.sleep(1/fps)
  299. frame = detected_frames_processor.get_current_frame(camera_name, draw_options)
  300. if frame is None:
  301. frame = np.zeros((height,int(height*16/9),3), np.uint8)
  302. width = int(height*frame.shape[1]/frame.shape[0])
  303. frame = cv2.resize(frame, dsize=(width, height), interpolation=cv2.INTER_LINEAR)
  304. ret, jpg = cv2.imencode('.jpg', frame, [int(cv2.IMWRITE_JPEG_QUALITY), 70])
  305. yield (b'--frame\r\n'
  306. b'Content-Type: image/jpeg\r\n\r\n' + jpg.tobytes() + b'\r\n\r\n')
  307. @ws.route('/ws')
  308. def echo_socket(socket):
  309. current_app.mqtt_backend.register(socket)
  310. while not socket.closed:
  311. # Sleep to prevent *constant* context-switches.
  312. gevent.sleep(0.1)
  313. message = socket.receive()
  314. if message:
  315. current_app.mqtt_backend.publish(message)