mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-02-25 18:55:25 -06:00
add jpg snapshots to disk and clean up config
This commit is contained in:
parent
d8c9169af2
commit
9dc97d4b6b
42
README.md
42
README.md
@ -34,6 +34,7 @@ Use of a [Google Coral Accelerator](https://coral.ai/products/) is optional, but
|
|||||||
- [Masks](#masks)
|
- [Masks](#masks)
|
||||||
- [Zones](#zones)
|
- [Zones](#zones)
|
||||||
- [Recording Clips (save_clips)](#recording-clips)
|
- [Recording Clips (save_clips)](#recording-clips)
|
||||||
|
- [Snapshots (snapshots)](#snapshots)
|
||||||
- [24/7 Recordings (record)](#247-recordings)
|
- [24/7 Recordings (record)](#247-recordings)
|
||||||
- [RTMP Streams (rtmp)](#rtmp-streams)
|
- [RTMP Streams (rtmp)](#rtmp-streams)
|
||||||
- [Integration with HomeAssistant](#integration-with-homeassistant)
|
- [Integration with HomeAssistant](#integration-with-homeassistant)
|
||||||
@ -428,20 +429,34 @@ cameras:
|
|||||||
# Required: Enable the live stream (default: True)
|
# Required: Enable the live stream (default: True)
|
||||||
enabled: True
|
enabled: True
|
||||||
|
|
||||||
# Optional: Configuration for the snapshots in the debug view and mqtt
|
# Optional: Configuration for the jpg snapshots written to the clips directory for each event
|
||||||
snapshots:
|
snapshots:
|
||||||
|
# Optional: Enable writing jpg snapshot to /media/frigate/clips (default: shown below)
|
||||||
|
enabled: False
|
||||||
# Optional: print a timestamp on the snapshots (default: shown below)
|
# Optional: print a timestamp on the snapshots (default: shown below)
|
||||||
show_timestamp: True
|
timestamp: False
|
||||||
# Optional: draw zones on the debug mjpeg feed (default: shown below)
|
# Optional: draw bounding box on the snapshots (default: shown below)
|
||||||
draw_zones: False
|
bounding_box: False
|
||||||
# Optional: draw bounding boxes on the mqtt snapshots (default: shown below)
|
# Optional: crop the snapshot (default: shown below)
|
||||||
draw_bounding_boxes: True
|
crop: False
|
||||||
# Optional: crop the snapshot to the detection region (default: shown below)
|
# Optional: height to resize the snapshot to (default: original size)
|
||||||
crop_to_region: True
|
|
||||||
# Optional: height to resize the snapshot to (default: shown below)
|
|
||||||
# NOTE: 175px is optimized for thumbnails in the homeassistant media browser
|
|
||||||
height: 175
|
height: 175
|
||||||
|
|
||||||
|
# Optional: Configuration for the jpg snapshots published via MQTT
|
||||||
|
mqtt:
|
||||||
|
# Optional: Enable publishing snapshot via mqtt for camera (default: shown below)
|
||||||
|
# NOTE: Only applies to publishing image data to MQTT via 'frigate/<camera_name>/<object_name>/snapshot'.
|
||||||
|
# All other messages will still be published.
|
||||||
|
enabled: True
|
||||||
|
# Optional: print a timestamp on the snapshots (default: shown below)
|
||||||
|
timestamp: True
|
||||||
|
# Optional: draw bounding box on the snapshots (default: shown below)
|
||||||
|
bounding_box: True
|
||||||
|
# Optional: crop the snapshot (default: shown below)
|
||||||
|
crop: True
|
||||||
|
# Optional: height to resize the snapshot to (default: shown below)
|
||||||
|
height: 270
|
||||||
|
|
||||||
# Optional: Camera level object filters config. If defined, this is used instead of the global config.
|
# Optional: Camera level object filters config. If defined, this is used instead of the global config.
|
||||||
objects:
|
objects:
|
||||||
track:
|
track:
|
||||||
@ -680,6 +695,10 @@ If you are storing your clips on a network share (SMB, NFS, etc), you may get a
|
|||||||
- `post_capture`: Defines how much time should be included in the clip after the end of the event. Defaults to 5 seconds.
|
- `post_capture`: Defines how much time should be included in the clip after the end of the event. Defaults to 5 seconds.
|
||||||
- `objects`: List of object types to save clips for. Object types here must be listed for tracking at the camera or global configuration. Defaults to all tracked objects.
|
- `objects`: List of object types to save clips for. Object types here must be listed for tracking at the camera or global configuration. Defaults to all tracked objects.
|
||||||
|
|
||||||
|
[Back to top](#documentation)
|
||||||
|
|
||||||
|
## Snapshots
|
||||||
|
Frigate can save a snapshot image to `/media/frigate/clips` for each event named as `<camera>-<id>.jpg`.
|
||||||
|
|
||||||
[Back to top](#documentation)
|
[Back to top](#documentation)
|
||||||
|
|
||||||
@ -874,6 +893,9 @@ Returns a snapshot for the event id optimized for notifications. Works while the
|
|||||||
### `/clips/<camera>-<id>.mp4`
|
### `/clips/<camera>-<id>.mp4`
|
||||||
Video clip for the given camera and event id.
|
Video clip for the given camera and event id.
|
||||||
|
|
||||||
|
### `/clips/<camera>-<id>.jpg`
|
||||||
|
JPG snapshot for the given camera and event id.
|
||||||
|
|
||||||
[Back to top](#documentation)
|
[Back to top](#documentation)
|
||||||
|
|
||||||
## MQTT Topics
|
## MQTT Topics
|
||||||
|
@ -192,11 +192,18 @@ CAMERAS_SCHEMA = vol.Schema(vol.All(
|
|||||||
vol.Required('enabled', default=True): bool,
|
vol.Required('enabled', default=True): bool,
|
||||||
},
|
},
|
||||||
vol.Optional('snapshots', default={}): {
|
vol.Optional('snapshots', default={}): {
|
||||||
vol.Optional('show_timestamp', default=True): bool,
|
vol.Optional('enabled', default=False): bool,
|
||||||
vol.Optional('draw_zones', default=False): bool,
|
vol.Optional('timestamp', default=False): bool,
|
||||||
vol.Optional('draw_bounding_boxes', default=True): bool,
|
vol.Optional('bounding_box', default=False): bool,
|
||||||
vol.Optional('crop_to_region', default=True): bool,
|
vol.Optional('crop', default=False): bool,
|
||||||
vol.Optional('height', default=175): int
|
'height': int
|
||||||
|
},
|
||||||
|
vol.Optional('mqtt', default={}): {
|
||||||
|
vol.Optional('enabled', default=True): bool,
|
||||||
|
vol.Optional('timestamp', default=True): bool,
|
||||||
|
vol.Optional('bounding_box', default=True): bool,
|
||||||
|
vol.Optional('crop', default=True): bool,
|
||||||
|
vol.Optional('height', default=270): int
|
||||||
},
|
},
|
||||||
'objects': OBJECTS_SCHEMA,
|
'objects': OBJECTS_SCHEMA,
|
||||||
vol.Optional('motion', default={}): MOTION_SCHEMA,
|
vol.Optional('motion', default={}): MOTION_SCHEMA,
|
||||||
@ -510,27 +517,27 @@ class ObjectConfig():
|
|||||||
|
|
||||||
class CameraSnapshotsConfig():
|
class CameraSnapshotsConfig():
|
||||||
def __init__(self, config):
|
def __init__(self, config):
|
||||||
self._show_timestamp = config['show_timestamp']
|
self._enabled = config['enabled']
|
||||||
self._draw_zones = config['draw_zones']
|
self._timestamp = config['timestamp']
|
||||||
self._draw_bounding_boxes = config['draw_bounding_boxes']
|
self._bounding_box = config['bounding_box']
|
||||||
self._crop_to_region = config['crop_to_region']
|
self._crop = config['crop']
|
||||||
self._height = config.get('height')
|
self._height = config.get('height')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def show_timestamp(self):
|
def enabled(self):
|
||||||
return self._show_timestamp
|
return self._enabled
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def draw_zones(self):
|
def timestamp(self):
|
||||||
return self._draw_zones
|
return self._timestamp
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def draw_bounding_boxes(self):
|
def bounding_box(self):
|
||||||
return self._draw_bounding_boxes
|
return self._bounding_box
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def crop_to_region(self):
|
def crop(self):
|
||||||
return self._crop_to_region
|
return self._crop
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def height(self):
|
def height(self):
|
||||||
@ -538,10 +545,47 @@ class CameraSnapshotsConfig():
|
|||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
'show_timestamp': self.show_timestamp,
|
'enabled': self.enabled,
|
||||||
'draw_zones': self.draw_zones,
|
'timestamp': self.timestamp,
|
||||||
'draw_bounding_boxes': self.draw_bounding_boxes,
|
'bounding_box': self.bounding_box,
|
||||||
'crop_to_region': self.crop_to_region,
|
'crop': self.crop,
|
||||||
|
'height': self.height
|
||||||
|
}
|
||||||
|
|
||||||
|
class CameraMqttConfig():
|
||||||
|
def __init__(self, config):
|
||||||
|
self._enabled = config['enabled']
|
||||||
|
self._timestamp = config['timestamp']
|
||||||
|
self._bounding_box = config['bounding_box']
|
||||||
|
self._crop = config['crop']
|
||||||
|
self._height = config.get('height')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self):
|
||||||
|
return self._enabled
|
||||||
|
|
||||||
|
@property
|
||||||
|
def timestamp(self):
|
||||||
|
return self._timestamp
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bounding_box(self):
|
||||||
|
return self._bounding_box
|
||||||
|
|
||||||
|
@property
|
||||||
|
def crop(self):
|
||||||
|
return self._crop
|
||||||
|
|
||||||
|
@property
|
||||||
|
def height(self):
|
||||||
|
return self._height
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'enabled': self.enabled,
|
||||||
|
'timestamp': self.timestamp,
|
||||||
|
'bounding_box': self.bounding_box,
|
||||||
|
'crop': self.crop,
|
||||||
'height': self.height
|
'height': self.height
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -708,6 +752,7 @@ class CameraConfig():
|
|||||||
self._record = RecordConfig(global_config['record'], config['record'])
|
self._record = RecordConfig(global_config['record'], config['record'])
|
||||||
self._rtmp = CameraRtmpConfig(global_config, config['rtmp'])
|
self._rtmp = CameraRtmpConfig(global_config, config['rtmp'])
|
||||||
self._snapshots = CameraSnapshotsConfig(config['snapshots'])
|
self._snapshots = CameraSnapshotsConfig(config['snapshots'])
|
||||||
|
self._mqtt = CameraMqttConfig(config['mqtt'])
|
||||||
self._objects = ObjectConfig(global_config['objects'], config.get('objects', {}))
|
self._objects = ObjectConfig(global_config['objects'], config.get('objects', {}))
|
||||||
self._motion = MotionConfig(global_config['motion'], config['motion'], self._height)
|
self._motion = MotionConfig(global_config['motion'], config['motion'], self._height)
|
||||||
self._detect = DetectConfig(global_config['detect'], config['detect'], config.get('fps', 5))
|
self._detect = DetectConfig(global_config['detect'], config['detect'], config.get('fps', 5))
|
||||||
@ -842,6 +887,10 @@ class CameraConfig():
|
|||||||
def snapshots(self):
|
def snapshots(self):
|
||||||
return self._snapshots
|
return self._snapshots
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mqtt(self):
|
||||||
|
return self._mqtt
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def objects(self):
|
def objects(self):
|
||||||
return self._objects
|
return self._objects
|
||||||
@ -878,6 +927,7 @@ class CameraConfig():
|
|||||||
'record': self.record.to_dict(),
|
'record': self.record.to_dict(),
|
||||||
'rtmp': self.rtmp.to_dict(),
|
'rtmp': self.rtmp.to_dict(),
|
||||||
'snapshots': self.snapshots.to_dict(),
|
'snapshots': self.snapshots.to_dict(),
|
||||||
|
'mqtt': self.mqtt.to_dict(),
|
||||||
'objects': self.objects.to_dict(),
|
'objects': self.objects.to_dict(),
|
||||||
'motion': self.motion.to_dict(),
|
'motion': self.motion.to_dict(),
|
||||||
'detect': self.detect.to_dict(),
|
'detect': self.detect.to_dict(),
|
||||||
|
@ -74,9 +74,6 @@ class TrackedObject():
|
|||||||
self.thumbnail_data = None
|
self.thumbnail_data = None
|
||||||
self.frame = None
|
self.frame = None
|
||||||
self.previous = self.to_dict()
|
self.previous = self.to_dict()
|
||||||
self._snapshot_jpg_time = 0
|
|
||||||
ret, jpg = cv2.imencode('.jpg', np.zeros((300,300,3), np.uint8))
|
|
||||||
self._snapshot_jpg = jpg.tobytes()
|
|
||||||
|
|
||||||
# start the score history
|
# start the score history
|
||||||
self.score_history = [self.obj_data['score']]
|
self.score_history = [self.obj_data['score']]
|
||||||
@ -167,41 +164,43 @@ class TrackedObject():
|
|||||||
'region': self.obj_data['region'],
|
'region': self.obj_data['region'],
|
||||||
'current_zones': self.current_zones.copy(),
|
'current_zones': self.current_zones.copy(),
|
||||||
'entered_zones': list(self.entered_zones).copy(),
|
'entered_zones': list(self.entered_zones).copy(),
|
||||||
'thumbnail': base64.b64encode(self.get_jpg_bytes()).decode('utf-8') if include_thumbnail else None
|
'thumbnail': base64.b64encode(self.get_thumbnail()).decode('utf-8') if include_thumbnail else None
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_jpg_bytes(self):
|
|
||||||
if self.thumbnail_data is None or self._snapshot_jpg_time == self.thumbnail_data['frame_time']:
|
|
||||||
return self._snapshot_jpg
|
|
||||||
|
|
||||||
if not self.thumbnail_data['frame_time'] in self.frame_cache:
|
if not self.thumbnail_data['frame_time'] in self.frame_cache:
|
||||||
logger.error(f"Unable to create thumbnail for {self.obj_data['id']}")
|
logger.error(f"Unable to create thumbnail for {self.obj_data['id']}")
|
||||||
logger.error(f"Looking for frame_time of {self.thumbnail_data['frame_time']}")
|
logger.error(f"Looking for frame_time of {self.thumbnail_data['frame_time']}")
|
||||||
logger.error(f"Thumbnail frames: {','.join([str(k) for k in self.frame_cache.keys()])}")
|
logger.error(f"Thumbnail frames: {','.join([str(k) for k in self.frame_cache.keys()])}")
|
||||||
return self._snapshot_jpg
|
ret, jpg = cv2.imencode('.jpg', np.zeros((175,175,3), np.uint8))
|
||||||
|
|
||||||
# TODO: crop first to avoid converting the entire frame?
|
jpg_bytes = self.get_jpg_bytes(timestamp=False, bounding_box=False, crop=True, height=175)
|
||||||
snapshot_config = self.camera_config.snapshots
|
|
||||||
|
if jpg_bytes:
|
||||||
|
return jpg_bytes
|
||||||
|
else:
|
||||||
|
ret, jpg = cv2.imencode('.jpg', np.zeros((175,175,3), np.uint8))
|
||||||
|
return jpg.tobytes()
|
||||||
|
|
||||||
|
def get_jpg_bytes(self, timestamp=False, bounding_box=False, crop=False, height=None):
|
||||||
best_frame = cv2.cvtColor(self.frame_cache[self.thumbnail_data['frame_time']], cv2.COLOR_YUV2BGR_I420)
|
best_frame = cv2.cvtColor(self.frame_cache[self.thumbnail_data['frame_time']], cv2.COLOR_YUV2BGR_I420)
|
||||||
|
|
||||||
if snapshot_config.draw_bounding_boxes:
|
if bounding_box:
|
||||||
thickness = 2
|
thickness = 2
|
||||||
color = COLOR_MAP[self.obj_data['label']]
|
color = COLOR_MAP[self.obj_data['label']]
|
||||||
box = self.thumbnail_data['box']
|
|
||||||
draw_box_with_label(best_frame, box[0], box[1], box[2], box[3], self.obj_data['label'],
|
|
||||||
f"{int(self.thumbnail_data['score']*100)}% {int(self.thumbnail_data['area'])}", thickness=thickness, color=color)
|
|
||||||
|
|
||||||
if snapshot_config.crop_to_region:
|
# draw the bounding boxes on the frame
|
||||||
|
box = self.thumbnail_data['box']
|
||||||
|
draw_box_with_label(best_frame, box[0], box[1], box[2], box[3], self.obj_data['label'], f"{int(self.thumbnail_data['score']*100)}% {int(self.thumbnail_data['area'])}", thickness=thickness, color=color)
|
||||||
|
|
||||||
|
if crop:
|
||||||
box = self.thumbnail_data['box']
|
box = self.thumbnail_data['box']
|
||||||
region = calculate_region(best_frame.shape, box[0], box[1], box[2], box[3], 1.1)
|
region = calculate_region(best_frame.shape, box[0], box[1], box[2], box[3], 1.1)
|
||||||
best_frame = best_frame[region[1]:region[3], region[0]:region[2]]
|
best_frame = best_frame[region[1]:region[3], region[0]:region[2]]
|
||||||
|
|
||||||
if snapshot_config.height:
|
if height:
|
||||||
height = snapshot_config.height
|
|
||||||
width = int(height*best_frame.shape[1]/best_frame.shape[0])
|
width = int(height*best_frame.shape[1]/best_frame.shape[0])
|
||||||
best_frame = cv2.resize(best_frame, dsize=(width, height), interpolation=cv2.INTER_AREA)
|
best_frame = cv2.resize(best_frame, dsize=(width, height), interpolation=cv2.INTER_AREA)
|
||||||
|
|
||||||
if snapshot_config.show_timestamp:
|
if timestamp:
|
||||||
time_to_show = datetime.datetime.fromtimestamp(self.thumbnail_data['frame_time']).strftime("%m/%d/%Y %H:%M:%S")
|
time_to_show = datetime.datetime.fromtimestamp(self.thumbnail_data['frame_time']).strftime("%m/%d/%Y %H:%M:%S")
|
||||||
size = cv2.getTextSize(time_to_show, cv2.FONT_HERSHEY_SIMPLEX, fontScale=1, thickness=2)
|
size = cv2.getTextSize(time_to_show, cv2.FONT_HERSHEY_SIMPLEX, fontScale=1, thickness=2)
|
||||||
text_width = size[0][0]
|
text_width = size[0][0]
|
||||||
@ -212,9 +211,9 @@ class TrackedObject():
|
|||||||
|
|
||||||
ret, jpg = cv2.imencode('.jpg', best_frame)
|
ret, jpg = cv2.imencode('.jpg', best_frame)
|
||||||
if ret:
|
if ret:
|
||||||
self._snapshot_jpg = jpg.tobytes()
|
return jpg.tobytes()
|
||||||
|
else:
|
||||||
return self._snapshot_jpg
|
return None
|
||||||
|
|
||||||
def zone_filtered(obj: TrackedObject, object_config):
|
def zone_filtered(obj: TrackedObject, object_config):
|
||||||
object_name = obj.obj_data['label']
|
object_name = obj.obj_data['label']
|
||||||
@ -432,13 +431,32 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
obj.previous = after
|
obj.previous = after
|
||||||
|
|
||||||
def end(camera, obj: TrackedObject, current_frame_time):
|
def end(camera, obj: TrackedObject, current_frame_time):
|
||||||
|
snapshot_config = self.config.cameras[camera].snapshots
|
||||||
if not obj.false_positive:
|
if not obj.false_positive:
|
||||||
message = { 'before': obj.previous, 'after': obj.to_dict() }
|
message = { 'before': obj.previous, 'after': obj.to_dict() }
|
||||||
self.client.publish(f"{self.topic_prefix}/events", json.dumps(message), retain=False)
|
self.client.publish(f"{self.topic_prefix}/events", json.dumps(message), retain=False)
|
||||||
|
# write snapshot to disk if enabled
|
||||||
|
if snapshot_config.enabled:
|
||||||
|
jpg_bytes = obj.get_jpg_bytes(
|
||||||
|
timestamp=snapshot_config.timestamp,
|
||||||
|
bounding_box=snapshot_config.bounding_box,
|
||||||
|
crop=snapshot_config.crop,
|
||||||
|
height=snapshot_config.height
|
||||||
|
)
|
||||||
|
with open(os.path.join(CLIPS_DIR, f"{camera}-{obj.obj_data['id']}.jpg"), 'wb') as j:
|
||||||
|
j.write(jpg_bytes)
|
||||||
self.event_queue.put(('end', camera, obj.to_dict(include_thumbnail=True)))
|
self.event_queue.put(('end', camera, obj.to_dict(include_thumbnail=True)))
|
||||||
|
|
||||||
def snapshot(camera, obj: TrackedObject, current_frame_time):
|
def snapshot(camera, obj: TrackedObject, current_frame_time):
|
||||||
self.client.publish(f"{self.topic_prefix}/{camera}/{obj.obj_data['label']}/snapshot", obj.get_jpg_bytes(), retain=True)
|
mqtt_config = self.config.cameras[camera].mqtt
|
||||||
|
if mqtt_config.enabled:
|
||||||
|
jpg_bytes = obj.get_jpg_bytes(
|
||||||
|
timestamp=mqtt_config.timestamp,
|
||||||
|
bounding_box=mqtt_config.bounding_box,
|
||||||
|
crop=mqtt_config.crop,
|
||||||
|
height=mqtt_config.height
|
||||||
|
)
|
||||||
|
self.client.publish(f"{self.topic_prefix}/{camera}/{obj.obj_data['label']}/snapshot", jpg_bytes, retain=True)
|
||||||
|
|
||||||
def object_status(camera, object_name, status):
|
def object_status(camera, object_name, status):
|
||||||
self.client.publish(f"{self.topic_prefix}/{camera}/{object_name}", status, retain=False)
|
self.client.publish(f"{self.topic_prefix}/{camera}/{object_name}", status, retain=False)
|
||||||
|
Loading…
Reference in New Issue
Block a user