Remove thumb from database field (#16647)

* Remove thumbnail from dict

* Create thumbnail diectory

* Cleanup handling of tracked object images

* Make thumbnail optional

* Handle cases where thumbnail is used

* Expand options for thumbnail api

* Fix up the does not exist condition

* Remove absolute usages of thumbnails

* Write thumbnails for external events

* Reduce webp quality

* Use webp everywhere in frontend

* Formatting

* Always consider all events when re-indexing

* Add thumbnail deletion and cleanup path management

* Cleanup imports

* Rename def

* Don't save thumbnail for every object

* Correct event count

* Use correct function

* Include thumbnail in query

* Remove unused

* Fix requiring exception
This commit is contained in:
Nicolas Mowen 2025-02-18 07:46:29 -07:00 committed by GitHub
parent 5bd412071a
commit f49a8009ec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 241 additions and 137 deletions

View File

@ -1,6 +1,5 @@
"""Image and video apis.""" """Image and video apis."""
import base64
import glob import glob
import logging import logging
import os import os
@ -40,6 +39,7 @@ from frigate.models import Event, Previews, Recordings, Regions, ReviewSegment
from frigate.object_processing import TrackedObjectProcessor from frigate.object_processing import TrackedObjectProcessor
from frigate.util.builtin import get_tz_modifiers from frigate.util.builtin import get_tz_modifiers
from frigate.util.image import get_image_from_recording from frigate.util.image import get_image_from_recording
from frigate.util.path import get_event_thumbnail_bytes
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -804,10 +804,11 @@ def event_snapshot(
) )
@router.get("/events/{event_id}/thumbnail.jpg") @router.get("/events/{event_id}/thumbnail.{extension}")
def event_thumbnail( def event_thumbnail(
request: Request, request: Request,
event_id: str, event_id: str,
extension: str,
max_cache_age: int = Query( max_cache_age: int = Query(
2592000, description="Max cache age in seconds. Default 30 days in seconds." 2592000, description="Max cache age in seconds. Default 30 days in seconds."
), ),
@ -816,11 +817,15 @@ def event_thumbnail(
thumbnail_bytes = None thumbnail_bytes = None
event_complete = False event_complete = False
try: try:
event = Event.get(Event.id == event_id) event: Event = Event.get(Event.id == event_id)
if event.end_time is not None: if event.end_time is not None:
event_complete = True event_complete = True
thumbnail_bytes = base64.b64decode(event.thumbnail)
thumbnail_bytes = get_event_thumbnail_bytes(event)
except DoesNotExist: except DoesNotExist:
thumbnail_bytes = None
if thumbnail_bytes is None:
# see if the object is currently being tracked # see if the object is currently being tracked
try: try:
camera_states = request.app.detected_frames_processor.camera_states.values() camera_states = request.app.detected_frames_processor.camera_states.values()
@ -828,7 +833,7 @@ def event_thumbnail(
if event_id in camera_state.tracked_objects: if event_id in camera_state.tracked_objects:
tracked_obj = camera_state.tracked_objects.get(event_id) tracked_obj = camera_state.tracked_objects.get(event_id)
if tracked_obj is not None: if tracked_obj is not None:
thumbnail_bytes = tracked_obj.get_thumbnail() thumbnail_bytes = tracked_obj.get_thumbnail(extension)
except Exception: except Exception:
return JSONResponse( return JSONResponse(
content={"success": False, "message": "Event not found"}, content={"success": False, "message": "Event not found"},
@ -843,8 +848,8 @@ def event_thumbnail(
# android notifications prefer a 2:1 ratio # android notifications prefer a 2:1 ratio
if format == "android": if format == "android":
jpg_as_np = np.frombuffer(thumbnail_bytes, dtype=np.uint8) img_as_np = np.frombuffer(thumbnail_bytes, dtype=np.uint8)
img = cv2.imdecode(jpg_as_np, flags=1) img = cv2.imdecode(img_as_np, flags=1)
thumbnail = cv2.copyMakeBorder( thumbnail = cv2.copyMakeBorder(
img, img,
0, 0,
@ -854,17 +859,25 @@ def event_thumbnail(
cv2.BORDER_CONSTANT, cv2.BORDER_CONSTANT,
(0, 0, 0), (0, 0, 0),
) )
ret, jpg = cv2.imencode(".jpg", thumbnail, [int(cv2.IMWRITE_JPEG_QUALITY), 70])
thumbnail_bytes = jpg.tobytes() quality_params = None
if extension == "jpg" or extension == "jpeg":
quality_params = [int(cv2.IMWRITE_JPEG_QUALITY), 70]
elif extension == "webp":
quality_params = [int(cv2.IMWRITE_WEBP_QUALITY), 60]
_, img = cv2.imencode(f".{img}", thumbnail, quality_params)
thumbnail_bytes = img.tobytes()
return Response( return Response(
thumbnail_bytes, thumbnail_bytes,
media_type="image/jpeg", media_type=f"image/{extension}",
headers={ headers={
"Cache-Control": f"private, max-age={max_cache_age}" "Cache-Control": f"private, max-age={max_cache_age}"
if event_complete if event_complete
else "no-store", else "no-store",
"Content-Type": "image/jpeg", "Content-Type": f"image/{extension}",
}, },
) )

View File

@ -39,6 +39,7 @@ from frigate.const import (
MODEL_CACHE_DIR, MODEL_CACHE_DIR,
RECORD_DIR, RECORD_DIR,
SHM_FRAMES_VAR, SHM_FRAMES_VAR,
THUMB_DIR,
) )
from frigate.data_processing.types import DataProcessorMetrics from frigate.data_processing.types import DataProcessorMetrics
from frigate.db.sqlitevecq import SqliteVecQueueDatabase from frigate.db.sqlitevecq import SqliteVecQueueDatabase
@ -105,6 +106,7 @@ class FrigateApp:
dirs = [ dirs = [
CONFIG_DIR, CONFIG_DIR,
RECORD_DIR, RECORD_DIR,
THUMB_DIR,
f"{CLIPS_DIR}/cache", f"{CLIPS_DIR}/cache",
CACHE_DIR, CACHE_DIR,
MODEL_CACHE_DIR, MODEL_CACHE_DIR,

View File

@ -7,6 +7,7 @@ BASE_DIR = "/media/frigate"
CLIPS_DIR = f"{BASE_DIR}/clips" CLIPS_DIR = f"{BASE_DIR}/clips"
EXPORT_DIR = f"{BASE_DIR}/exports" EXPORT_DIR = f"{BASE_DIR}/exports"
FACE_DIR = f"{CLIPS_DIR}/faces" FACE_DIR = f"{CLIPS_DIR}/faces"
THUMB_DIR = f"{CLIPS_DIR}/thumbs"
RECORD_DIR = f"{BASE_DIR}/recordings" RECORD_DIR = f"{BASE_DIR}/recordings"
BIRDSEYE_PIPE = "/tmp/cache/birdseye" BIRDSEYE_PIPE = "/tmp/cache/birdseye"
CACHE_DIR = "/tmp/cache" CACHE_DIR = "/tmp/cache"

View File

@ -1,6 +1,5 @@
"""SQLite-vec embeddings database.""" """SQLite-vec embeddings database."""
import base64
import datetime import datetime
import logging import logging
import os import os
@ -21,6 +20,7 @@ from frigate.db.sqlitevecq import SqliteVecQueueDatabase
from frigate.models import Event from frigate.models import Event
from frigate.types import ModelStatusTypesEnum from frigate.types import ModelStatusTypesEnum
from frigate.util.builtin import serialize from frigate.util.builtin import serialize
from frigate.util.path import get_event_thumbnail_bytes
from .functions.onnx import GenericONNXEmbedding, ModelTypeEnum from .functions.onnx import GenericONNXEmbedding, ModelTypeEnum
@ -264,14 +264,7 @@ class Embeddings:
st = time.time() st = time.time()
# Get total count of events to process # Get total count of events to process
total_events = ( total_events = Event.select().count()
Event.select()
.where(
(Event.has_clip == True | Event.has_snapshot == True)
& Event.thumbnail.is_null(False)
)
.count()
)
batch_size = 32 batch_size = 32
current_page = 1 current_page = 1
@ -289,10 +282,6 @@ class Embeddings:
events = ( events = (
Event.select() Event.select()
.where(
(Event.has_clip == True | Event.has_snapshot == True)
& Event.thumbnail.is_null(False)
)
.order_by(Event.start_time.desc()) .order_by(Event.start_time.desc())
.paginate(current_page, batch_size) .paginate(current_page, batch_size)
) )
@ -302,7 +291,12 @@ class Embeddings:
batch_thumbs = {} batch_thumbs = {}
batch_descs = {} batch_descs = {}
for event in events: for event in events:
batch_thumbs[event.id] = base64.b64decode(event.thumbnail) thumbnail = get_event_thumbnail_bytes(event)
if thumbnail is None:
continue
batch_thumbs[event.id] = thumbnail
totals["thumbnails"] += 1 totals["thumbnails"] += 1
if description := event.data.get("description", "").strip(): if description := event.data.get("description", "").strip():
@ -341,10 +335,6 @@ class Embeddings:
current_page += 1 current_page += 1
events = ( events = (
Event.select() Event.select()
.where(
(Event.has_clip == True | Event.has_snapshot == True)
& Event.thumbnail.is_null(False)
)
.order_by(Event.start_time.desc()) .order_by(Event.start_time.desc())
.paginate(current_page, batch_size) .paginate(current_page, batch_size)
) )

View File

@ -38,6 +38,7 @@ from frigate.models import Event
from frigate.types import TrackedObjectUpdateTypesEnum from frigate.types import TrackedObjectUpdateTypesEnum
from frigate.util.builtin import serialize from frigate.util.builtin import serialize
from frigate.util.image import SharedMemoryFrameManager, calculate_region from frigate.util.image import SharedMemoryFrameManager, calculate_region
from frigate.util.path import get_event_thumbnail_bytes
from .embeddings import Embeddings from .embeddings import Embeddings
@ -215,7 +216,7 @@ class EmbeddingMaintainer(threading.Thread):
continue continue
# Extract valid thumbnail # Extract valid thumbnail
thumbnail = base64.b64decode(event.thumbnail) thumbnail = get_event_thumbnail_bytes(event)
# Embed the thumbnail # Embed the thumbnail
self._embed_thumbnail(event_id, thumbnail) self._embed_thumbnail(event_id, thumbnail)
@ -390,7 +391,7 @@ class EmbeddingMaintainer(threading.Thread):
logger.error(f"GenAI not enabled for camera {event.camera}") logger.error(f"GenAI not enabled for camera {event.camera}")
return return
thumbnail = base64.b64decode(event.thumbnail) thumbnail = get_event_thumbnail_bytes(event)
logger.debug( logger.debug(
f"Trying {source} regeneration for {event}, has_snapshot: {event.has_snapshot}" f"Trying {source} regeneration for {event}, has_snapshot: {event.has_snapshot}"

View File

@ -11,6 +11,7 @@ from frigate.config import FrigateConfig
from frigate.const import CLIPS_DIR from frigate.const import CLIPS_DIR
from frigate.db.sqlitevecq import SqliteVecQueueDatabase from frigate.db.sqlitevecq import SqliteVecQueueDatabase
from frigate.models import Event, Timeline from frigate.models import Event, Timeline
from frigate.util.path import delete_event_images
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -64,7 +65,6 @@ class EventCleanup(threading.Thread):
def expire_snapshots(self) -> list[str]: def expire_snapshots(self) -> list[str]:
## Expire events from unlisted cameras based on the global config ## Expire events from unlisted cameras based on the global config
retain_config = self.config.snapshots.retain retain_config = self.config.snapshots.retain
file_extension = "jpg"
update_params = {"has_snapshot": False} update_params = {"has_snapshot": False}
distinct_labels = self.get_removed_camera_labels() distinct_labels = self.get_removed_camera_labels()
@ -83,6 +83,7 @@ class EventCleanup(threading.Thread):
Event.select( Event.select(
Event.id, Event.id,
Event.camera, Event.camera,
Event.thumbnail,
) )
.where( .where(
Event.camera.not_in(self.camera_keys), Event.camera.not_in(self.camera_keys),
@ -94,22 +95,15 @@ class EventCleanup(threading.Thread):
.iterator() .iterator()
) )
logger.debug(f"{len(list(expired_events))} events can be expired") logger.debug(f"{len(list(expired_events))} events can be expired")
# delete the media from disk # delete the media from disk
for expired in expired_events: for expired in expired_events:
media_name = f"{expired.camera}-{expired.id}" deleted = delete_event_images(expired)
media_path = Path(
f"{os.path.join(CLIPS_DIR, media_name)}.{file_extension}"
)
try: if not deleted:
media_path.unlink(missing_ok=True) logger.warning(
if file_extension == "jpg": f"Unable to delete event images for {expired.camera}: {expired.id}"
media_path = Path(
f"{os.path.join(CLIPS_DIR, media_name)}-clean.png"
) )
media_path.unlink(missing_ok=True)
except OSError as e:
logger.warning(f"Unable to delete event images: {e}")
# update the clips attribute for the db entry # update the clips attribute for the db entry
query = Event.select(Event.id).where( query = Event.select(Event.id).where(
@ -165,6 +159,7 @@ class EventCleanup(threading.Thread):
Event.select( Event.select(
Event.id, Event.id,
Event.camera, Event.camera,
Event.thumbnail,
) )
.where( .where(
Event.camera == name, Event.camera == name,
@ -181,19 +176,12 @@ class EventCleanup(threading.Thread):
# so no need to delete mp4 files # so no need to delete mp4 files
for event in expired_events: for event in expired_events:
events_to_update.append(event.id) events_to_update.append(event.id)
deleted = delete_event_images(event)
try: if not deleted:
media_name = f"{event.camera}-{event.id}" logger.warning(
media_path = Path( f"Unable to delete event images for {event.camera}: {event.id}"
f"{os.path.join(CLIPS_DIR, media_name)}.{file_extension}"
) )
media_path.unlink(missing_ok=True)
media_path = Path(
f"{os.path.join(CLIPS_DIR, media_name)}-clean.png"
)
media_path.unlink(missing_ok=True)
except OSError as e:
logger.warning(f"Unable to delete event images: {e}")
# update the clips attribute for the db entry # update the clips attribute for the db entry
for i in range(0, len(events_to_update), CHUNK_SIZE): for i in range(0, len(events_to_update), CHUNK_SIZE):

View File

@ -1,6 +1,5 @@
"""Handle external events created by the user.""" """Handle external events created by the user."""
import base64
import datetime import datetime
import logging import logging
import os import os
@ -15,7 +14,7 @@ from numpy import ndarray
from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum from frigate.comms.detections_updater import DetectionPublisher, DetectionTypeEnum
from frigate.comms.events_updater import EventUpdatePublisher from frigate.comms.events_updater import EventUpdatePublisher
from frigate.config import CameraConfig, FrigateConfig from frigate.config import CameraConfig, FrigateConfig
from frigate.const import CLIPS_DIR from frigate.const import CLIPS_DIR, THUMB_DIR
from frigate.events.types import EventStateEnum, EventTypeEnum from frigate.events.types import EventStateEnum, EventTypeEnum
from frigate.util.image import draw_box_with_label from frigate.util.image import draw_box_with_label
@ -55,9 +54,7 @@ class ExternalEventProcessor:
rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
event_id = f"{now}-{rand_id}" event_id = f"{now}-{rand_id}"
thumbnail = self._write_images( self._write_images(camera_config, label, event_id, draw, snapshot_frame)
camera_config, label, event_id, draw, snapshot_frame
)
end = now + duration if duration is not None else None end = now + duration if duration is not None else None
self.event_sender.publish( self.event_sender.publish(
@ -74,7 +71,6 @@ class ExternalEventProcessor:
"camera": camera, "camera": camera,
"start_time": now - camera_config.record.event_pre_capture, "start_time": now - camera_config.record.event_pre_capture,
"end_time": end, "end_time": end,
"thumbnail": thumbnail,
"has_clip": camera_config.record.enabled and include_recording, "has_clip": camera_config.record.enabled and include_recording,
"has_snapshot": True, "has_snapshot": True,
"type": source_type, "type": source_type,
@ -134,9 +130,9 @@ class ExternalEventProcessor:
event_id: str, event_id: str,
draw: dict[str, any], draw: dict[str, any],
img_frame: Optional[ndarray], img_frame: Optional[ndarray],
) -> Optional[str]: ) -> None:
if img_frame is None: if img_frame is None:
return None return
# write clean snapshot if enabled # write clean snapshot if enabled
if camera_config.snapshots.clean_copy: if camera_config.snapshots.clean_copy:
@ -182,8 +178,9 @@ class ExternalEventProcessor:
# create thumbnail with max height of 175 and save # create thumbnail with max height of 175 and save
width = int(175 * img_frame.shape[1] / img_frame.shape[0]) width = int(175 * img_frame.shape[1] / img_frame.shape[0])
thumb = cv2.resize(img_frame, dsize=(width, 175), interpolation=cv2.INTER_AREA) thumb = cv2.resize(img_frame, dsize=(width, 175), interpolation=cv2.INTER_AREA)
ret, jpg = cv2.imencode(".jpg", thumb) cv2.imwrite(
return base64.b64encode(jpg.tobytes()).decode("utf-8") os.path.join(THUMB_DIR, camera_config.name, f"{event_id}.webp"), thumb
)
def stop(self): def stop(self):
self.event_sender.stop() self.event_sender.stop()

View File

@ -23,7 +23,6 @@ def should_update_db(prev_event: Event, current_event: Event) -> bool:
if ( if (
prev_event["top_score"] != current_event["top_score"] prev_event["top_score"] != current_event["top_score"]
or prev_event["entered_zones"] != current_event["entered_zones"] or prev_event["entered_zones"] != current_event["entered_zones"]
or prev_event["thumbnail"] != current_event["thumbnail"]
or prev_event["end_time"] != current_event["end_time"] or prev_event["end_time"] != current_event["end_time"]
or prev_event["average_estimated_speed"] or prev_event["average_estimated_speed"]
!= current_event["average_estimated_speed"] != current_event["average_estimated_speed"]
@ -202,7 +201,7 @@ class EventProcessor(threading.Thread):
Event.start_time: start_time, Event.start_time: start_time,
Event.end_time: end_time, Event.end_time: end_time,
Event.zones: list(event_data["entered_zones"]), Event.zones: list(event_data["entered_zones"]),
Event.thumbnail: event_data["thumbnail"], Event.thumbnail: event_data.get("thumbnail"),
Event.has_clip: event_data["has_clip"], Event.has_clip: event_data["has_clip"],
Event.has_snapshot: event_data["has_snapshot"], Event.has_snapshot: event_data["has_snapshot"],
Event.model_hash: first_detector.model.model_hash, Event.model_hash: first_detector.model.model_hash,
@ -258,7 +257,7 @@ class EventProcessor(threading.Thread):
Event.camera: event_data["camera"], Event.camera: event_data["camera"],
Event.start_time: event_data["start_time"], Event.start_time: event_data["start_time"],
Event.end_time: event_data["end_time"], Event.end_time: event_data["end_time"],
Event.thumbnail: event_data["thumbnail"], Event.thumbnail: event_data.get("thumbnail"),
Event.has_clip: event_data["has_clip"], Event.has_clip: event_data["has_clip"],
Event.has_snapshot: event_data["has_snapshot"], Event.has_snapshot: event_data["has_snapshot"],
Event.zones: [], Event.zones: [],

View File

@ -1,7 +1,6 @@
import datetime import datetime
import json import json
import logging import logging
import os
import queue import queue
import threading import threading
from collections import defaultdict from collections import defaultdict
@ -16,13 +15,13 @@ from frigate.comms.dispatcher import Dispatcher
from frigate.comms.events_updater import EventEndSubscriber, EventUpdatePublisher from frigate.comms.events_updater import EventEndSubscriber, EventUpdatePublisher
from frigate.comms.inter_process import InterProcessRequestor from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import ( from frigate.config import (
CameraMqttConfig,
FrigateConfig, FrigateConfig,
MqttConfig,
RecordConfig, RecordConfig,
SnapshotsConfig, SnapshotsConfig,
ZoomingModeEnum, ZoomingModeEnum,
) )
from frigate.const import CLIPS_DIR, UPDATE_CAMERA_ACTIVITY from frigate.const import UPDATE_CAMERA_ACTIVITY
from frigate.events.types import EventStateEnum, EventTypeEnum from frigate.events.types import EventStateEnum, EventTypeEnum
from frigate.ptz.autotrack import PtzAutoTrackerThread from frigate.ptz.autotrack import PtzAutoTrackerThread
from frigate.track.tracked_object import TrackedObject from frigate.track.tracked_object import TrackedObject
@ -479,7 +478,7 @@ class TrackedObjectProcessor(threading.Thread):
EventStateEnum.update, EventStateEnum.update,
camera, camera,
frame_name, frame_name,
obj.to_dict(include_thumbnail=True), obj.to_dict(),
) )
) )
@ -491,41 +490,13 @@ class TrackedObjectProcessor(threading.Thread):
obj.has_snapshot = self.should_save_snapshot(camera, obj) obj.has_snapshot = self.should_save_snapshot(camera, obj)
obj.has_clip = self.should_retain_recording(camera, obj) obj.has_clip = self.should_retain_recording(camera, obj)
# write thumbnail to disk if it will be saved as an event
if obj.has_snapshot or obj.has_clip:
obj.write_thumbnail_to_disk()
# write the snapshot to disk # write the snapshot to disk
if obj.has_snapshot: if obj.has_snapshot:
snapshot_config: SnapshotsConfig = self.config.cameras[camera].snapshots obj.write_snapshot_to_disk()
jpg_bytes = obj.get_jpg_bytes(
timestamp=snapshot_config.timestamp,
bounding_box=snapshot_config.bounding_box,
crop=snapshot_config.crop,
height=snapshot_config.height,
quality=snapshot_config.quality,
)
if jpg_bytes is None:
logger.warning(f"Unable to save snapshot for {obj.obj_data['id']}.")
else:
with open(
os.path.join(CLIPS_DIR, f"{camera}-{obj.obj_data['id']}.jpg"),
"wb",
) as j:
j.write(jpg_bytes)
# write clean snapshot if enabled
if snapshot_config.clean_copy:
png_bytes = obj.get_clean_png()
if png_bytes is None:
logger.warning(
f"Unable to save clean snapshot for {obj.obj_data['id']}."
)
else:
with open(
os.path.join(
CLIPS_DIR,
f"{camera}-{obj.obj_data['id']}-clean.png",
),
"wb",
) as p:
p.write(png_bytes)
if not obj.false_positive: if not obj.false_positive:
message = { message = {
@ -542,14 +513,15 @@ class TrackedObjectProcessor(threading.Thread):
EventStateEnum.end, EventStateEnum.end,
camera, camera,
frame_name, frame_name,
obj.to_dict(include_thumbnail=True), obj.to_dict(),
) )
) )
def snapshot(camera, obj: TrackedObject, frame_name: str): def snapshot(camera, obj: TrackedObject, frame_name: str):
mqtt_config: MqttConfig = self.config.cameras[camera].mqtt mqtt_config: CameraMqttConfig = self.config.cameras[camera].mqtt
if mqtt_config.enabled and self.should_mqtt_snapshot(camera, obj): if mqtt_config.enabled and self.should_mqtt_snapshot(camera, obj):
jpg_bytes = obj.get_jpg_bytes( jpg_bytes = obj.get_img_bytes(
ext="jpg",
timestamp=mqtt_config.timestamp, timestamp=mqtt_config.timestamp,
bounding_box=mqtt_config.bounding_box, bounding_box=mqtt_config.bounding_box,
crop=mqtt_config.crop, crop=mqtt_config.crop,

View File

@ -1,8 +1,8 @@
"""Object attribute.""" """Object attribute."""
import base64
import logging import logging
import math import math
import os
from collections import defaultdict from collections import defaultdict
from statistics import median from statistics import median
from typing import Optional from typing import Optional
@ -13,8 +13,10 @@ import numpy as np
from frigate.config import ( from frigate.config import (
CameraConfig, CameraConfig,
ModelConfig, ModelConfig,
SnapshotsConfig,
UIConfig, UIConfig,
) )
from frigate.const import CLIPS_DIR, THUMB_DIR
from frigate.review.types import SeverityEnum from frigate.review.types import SeverityEnum
from frigate.util.image import ( from frigate.util.image import (
area, area,
@ -330,7 +332,7 @@ class TrackedObject:
self.current_zones = current_zones self.current_zones = current_zones
return (thumb_update, significant_change, autotracker_update) return (thumb_update, significant_change, autotracker_update)
def to_dict(self, include_thumbnail: bool = False): def to_dict(self):
event = { event = {
"id": self.obj_data["id"], "id": self.obj_data["id"],
"camera": self.camera_config.name, "camera": self.camera_config.name,
@ -365,9 +367,6 @@ class TrackedObject:
"path_data": self.path_data, "path_data": self.path_data,
} }
if include_thumbnail:
event["thumbnail"] = base64.b64encode(self.get_thumbnail()).decode("utf-8")
return event return event
def is_active(self): def is_active(self):
@ -379,22 +378,16 @@ class TrackedObject:
> self.camera_config.detect.stationary.threshold > self.camera_config.detect.stationary.threshold
) )
def get_thumbnail(self): def get_thumbnail(self, ext: str):
if ( img_bytes = self.get_img_bytes(
self.thumbnail_data is None ext, timestamp=False, bounding_box=False, crop=True, height=175
or self.thumbnail_data["frame_time"] not in self.frame_cache
):
ret, jpg = cv2.imencode(".jpg", np.zeros((175, 175, 3), np.uint8))
jpg_bytes = self.get_jpg_bytes(
timestamp=False, bounding_box=False, crop=True, height=175
) )
if jpg_bytes: if img_bytes:
return jpg_bytes return img_bytes
else: else:
ret, jpg = cv2.imencode(".jpg", np.zeros((175, 175, 3), np.uint8)) _, img = cv2.imencode(f".{ext}", np.zeros((175, 175, 3), np.uint8))
return jpg.tobytes() return img.tobytes()
def get_clean_png(self): def get_clean_png(self):
if self.thumbnail_data is None: if self.thumbnail_data is None:
@ -417,8 +410,14 @@ class TrackedObject:
else: else:
return None return None
def get_jpg_bytes( def get_img_bytes(
self, timestamp=False, bounding_box=False, crop=False, height=None, quality=70 self,
ext: str,
timestamp=False,
bounding_box=False,
crop=False,
height: int | None = None,
quality: int | None = None,
): ):
if self.thumbnail_data is None: if self.thumbnail_data is None:
return None return None
@ -503,14 +502,69 @@ class TrackedObject:
position=self.camera_config.timestamp_style.position, position=self.camera_config.timestamp_style.position,
) )
ret, jpg = cv2.imencode( quality_params = None
".jpg", best_frame, [int(cv2.IMWRITE_JPEG_QUALITY), quality]
) if ext == "jpg":
quality_params = [int(cv2.IMWRITE_JPEG_QUALITY), quality or 70]
elif ext == "webp":
quality_params = [int(cv2.IMWRITE_WEBP_QUALITY), quality or 60]
ret, jpg = cv2.imencode(f".{ext}", best_frame, quality_params)
if ret: if ret:
return jpg.tobytes() return jpg.tobytes()
else: else:
return None return None
def write_snapshot_to_disk(self) -> None:
snapshot_config: SnapshotsConfig = self.camera_config.snapshots
jpg_bytes = self.get_img_bytes(
ext="jpg",
timestamp=snapshot_config.timestamp,
bounding_box=snapshot_config.bounding_box,
crop=snapshot_config.crop,
height=snapshot_config.height,
quality=snapshot_config.quality,
)
if jpg_bytes is None:
logger.warning(f"Unable to save snapshot for {self.obj_data['id']}.")
else:
with open(
os.path.join(
CLIPS_DIR, f"{self.camera_config.name}-{self.obj_data['id']}.jpg"
),
"wb",
) as j:
j.write(jpg_bytes)
# write clean snapshot if enabled
if snapshot_config.clean_copy:
png_bytes = self.get_clean_png()
if png_bytes is None:
logger.warning(
f"Unable to save clean snapshot for {self.obj_data['id']}."
)
else:
with open(
os.path.join(
CLIPS_DIR,
f"{self.camera_config.name}-{self.obj_data['id']}-clean.png",
),
"wb",
) as p:
p.write(png_bytes)
def write_thumbnail_to_disk(self) -> None:
directory = os.path.join(THUMB_DIR, self.camera_config.name)
if not os.path.exists(directory):
os.makedirs(directory)
thumb_bytes = self.get_thumbnail("webp")
with open(os.path.join(directory, f"{self.obj_data['id']}.webp"), "wb") as f:
f.write(thumb_bytes)
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"]

51
frigate/util/path.py Normal file
View File

@ -0,0 +1,51 @@
"""Path utilities."""
import base64
import os
from pathlib import Path
from frigate.const import CLIPS_DIR, THUMB_DIR
from frigate.models import Event
def get_event_thumbnail_bytes(event: Event) -> bytes | None:
if event.thumbnail:
return base64.b64decode(event.thumbnail)
else:
try:
with open(
os.path.join(THUMB_DIR, event.camera, f"{event.id}.webp"), "rb"
) as f:
return f.read()
except Exception:
return None
### Deletion
def delete_event_images(event: Event) -> bool:
return delete_event_snapshot(event) and delete_event_thumbnail(event)
def delete_event_snapshot(event: Event) -> bool:
media_name = f"{event.camera}-{event.id}"
media_path = Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg")
try:
media_path.unlink(missing_ok=True)
media_path = Path(f"{os.path.join(CLIPS_DIR, media_name)}-clean.png")
media_path.unlink(missing_ok=True)
return True
except OSError:
return False
def delete_event_thumbnail(event: Event) -> bool:
if event.thumbnail:
return True
else:
Path(os.path.join(THUMB_DIR, event.camera, f"{event.id}.webp")).unlink(
missing_ok=True
)
return True

View File

@ -0,0 +1,36 @@
"""Peewee migrations -- 028_optional_event_thumbnail.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 peewee as pw
from frigate.models import Event
SQL = pw.SQL
def migrate(migrator, database, fake=False, **kwargs):
migrator.drop_not_null(Event, "thumbnail")
def rollback(migrator, database, fake=False, **kwargs):
migrator.add_not_null(Event, "thumbnail")

View File

@ -80,7 +80,7 @@ export default function SearchThumbnail({
: undefined : undefined
} }
draggable={false} draggable={false}
src={`${apiHost}api/events/${searchResult.id}/thumbnail.jpg`} src={`${apiHost}api/events/${searchResult.id}/thumbnail.webp`}
loading={isSafari ? "eager" : "lazy"} loading={isSafari ? "eager" : "lazy"}
onLoad={() => { onLoad={() => {
onImgLoad(); onImgLoad();

View File

@ -385,7 +385,7 @@ function EventItem({
src={ src={
event.has_snapshot event.has_snapshot
? `${apiHost}api/events/${event.id}/snapshot.jpg` ? `${apiHost}api/events/${event.id}/snapshot.jpg`
: `${apiHost}api/events/${event.id}/thumbnail.jpg` : `${apiHost}api/events/${event.id}/thumbnail.webp`
} }
/> />
{hovered && ( {hovered && (
@ -400,7 +400,7 @@ function EventItem({
href={ href={
event.has_snapshot event.has_snapshot
? `${apiHost}api/events/${event.id}/snapshot.jpg` ? `${apiHost}api/events/${event.id}/snapshot.jpg`
: `${apiHost}api/events/${event.id}/thumbnail.jpg` : `${apiHost}api/events/${event.id}/thumbnail.webp`
} }
> >
<Chip className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"> <Chip className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500">

View File

@ -511,7 +511,7 @@ function ObjectDetailsTab({
: undefined : undefined
} }
draggable={false} draggable={false}
src={`${apiHost}api/events/${search.id}/thumbnail.jpg`} src={`${apiHost}api/events/${search.id}/thumbnail.webp`}
/> />
{config?.semantic_search.enabled && search.data.type == "object" && ( {config?.semantic_search.enabled && search.data.type == "object" && (
<Button <Button

View File

@ -262,7 +262,7 @@ function ExploreThumbnailImage({
} }
loading={isSafari ? "eager" : "lazy"} loading={isSafari ? "eager" : "lazy"}
draggable={false} draggable={false}
src={`${apiHost}api/events/${event.id}/thumbnail.jpg`} src={`${apiHost}api/events/${event.id}/thumbnail.webp`}
onClick={() => setSearchDetail(event)} onClick={() => setSearchDetail(event)}
onLoad={onImgLoad} onLoad={onImgLoad}
alt={`${event.label} thumbnail`} alt={`${event.label} thumbnail`}