Files
frigate/frigate/output/preview.py
T

474 lines
16 KiB
Python
Raw Normal View History

"""Handle outputting low res / fps preview segments from decoded frames."""
import datetime
import logging
import os
import shutil
import subprocess as sp
import threading
2024-05-27 20:27:01 -06:00
import time
from pathlib import Path
2025-05-13 16:27:20 +02:00
from typing import Any
import cv2
import numpy as np
from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import CameraConfig, RecordQualityEnum
2024-03-11 17:31:05 -06:00
from frigate.const import CACHE_DIR, CLIPS_DIR, INSERT_PREVIEW, PREVIEW_FRAME_TYPE
from frigate.ffmpeg_presets import (
FPS_VFR_PARAM,
EncodeTypeEnum,
parse_preset_hardware_acceleration_encode,
)
from frigate.models import Previews
2025-03-05 07:07:48 -07:00
from frigate.util.image import copy_yuv_to_position, get_blank_yuv_frame, get_yuv_crop
logger = logging.getLogger(__name__)
FOLDER_PREVIEW_FRAMES = "preview_frames"
2024-03-05 12:56:38 -07:00
PREVIEW_CACHE_DIR = os.path.join(CACHE_DIR, FOLDER_PREVIEW_FRAMES)
PREVIEW_SEGMENT_DURATION = 3600 # one hour
# important to have lower keyframe to maintain scrubbing performance
PREVIEW_KEYFRAME_INTERVAL = 40
PREVIEW_HEIGHT = 180
PREVIEW_QUALITY_WEBP = {
RecordQualityEnum.very_low: 70,
RecordQualityEnum.low: 80,
RecordQualityEnum.medium: 80,
RecordQualityEnum.high: 80,
RecordQualityEnum.very_high: 86,
}
PREVIEW_QUALITY_BIT_RATES = {
RecordQualityEnum.very_low: 7168,
RecordQualityEnum.low: 8196,
RecordQualityEnum.medium: 9216,
RecordQualityEnum.high: 9864,
RecordQualityEnum.very_high: 10096,
}
# the -qmax param for ffmpeg prevents the encoder from overly compressing frames while still trying to hit the bitrate target
# lower values are higher quality. This is especially important for iniitial frames in the segment
PREVIEW_QMAX_PARAM = {
RecordQualityEnum.very_low: "",
RecordQualityEnum.low: "",
RecordQualityEnum.medium: "",
RecordQualityEnum.high: " -qmax 25",
RecordQualityEnum.very_high: " -qmax 25",
}
def get_cache_image_name(camera: str, frame_time: float) -> str:
"""Get the image name in cache."""
return os.path.join(
CACHE_DIR,
2024-03-11 17:31:05 -06:00
f"{FOLDER_PREVIEW_FRAMES}/preview_{camera}-{frame_time}.{PREVIEW_FRAME_TYPE}",
)
2026-03-25 12:53:19 -06:00
def get_most_recent_preview_frame(
camera: str, before: float | None = None
) -> str | None:
2026-01-22 11:21:41 -06:00
"""Get the most recent preview frame for a camera."""
if not os.path.exists(PREVIEW_CACHE_DIR):
return None
try:
# files are named preview_{camera}-{timestamp}.webp
# we want the largest timestamp that is less than or equal to before
preview_files = [
f
for f in os.listdir(PREVIEW_CACHE_DIR)
if f.startswith(f"preview_{camera}-")
and f.endswith(f".{PREVIEW_FRAME_TYPE}")
]
if not preview_files:
return None
# sort by timestamp in descending order
# filenames are like preview_front-1712345678.901234.webp
preview_files.sort(reverse=True)
if before is None:
return os.path.join(PREVIEW_CACHE_DIR, preview_files[0])
for file_name in preview_files:
try:
# Extract timestamp: preview_front-1712345678.901234.webp
# Split by dash and extension
timestamp_part = file_name.split("-")[-1].split(
f".{PREVIEW_FRAME_TYPE}"
)[0]
timestamp = float(timestamp_part)
if timestamp <= before:
return os.path.join(PREVIEW_CACHE_DIR, file_name)
except (ValueError, IndexError):
continue
return None
except Exception as e:
logger.error(f"Error searching for most recent preview frame: {e}")
return None
class FFMpegConverter(threading.Thread):
2024-03-11 17:31:05 -06:00
"""Convert a list of still frames into a vfr mp4."""
def __init__(
self,
config: CameraConfig,
frame_times: list[float],
requestor: InterProcessRequestor,
):
super().__init__(name=f"{config.name}_preview_converter")
self.config = config
self.frame_times = frame_times
self.requestor = requestor
self.path = os.path.join(
CLIPS_DIR,
f"previews/{self.config.name}/{self.frame_times[0]}-{self.frame_times[-1]}.mp4",
)
# write a PREVIEW at fps and 1 key frame per clip
self.ffmpeg_cmd = parse_preset_hardware_acceleration_encode(
2024-09-13 14:14:51 -06:00
config.ffmpeg.ffmpeg_path,
2024-11-07 16:24:38 -07:00
"default",
2024-07-26 08:16:45 -06:00
input="-f concat -y -protocol_whitelist pipe,file -safe 0 -threads 1 -i /dev/stdin",
output=f"-threads 1 -g {PREVIEW_KEYFRAME_INTERVAL} -bf 0 -b:v {PREVIEW_QUALITY_BIT_RATES[self.config.record.preview.quality]}{PREVIEW_QMAX_PARAM[self.config.record.preview.quality]} {FPS_VFR_PARAM} -movflags +faststart -pix_fmt yuv420p {self.path}",
type=EncodeTypeEnum.preview,
)
def run(self) -> None:
# generate input list
item_count = len(self.frame_times)
playlist = []
for t_idx in range(0, item_count):
if t_idx == item_count - 1:
# last frame does not get a duration
playlist.append(
2026-03-25 12:53:19 -06:00
f"file '{get_cache_image_name(self.config.name, self.frame_times[t_idx])}'" # type: ignore[arg-type]
)
continue
playlist.append(
2026-03-25 12:53:19 -06:00
f"file '{get_cache_image_name(self.config.name, self.frame_times[t_idx])}'" # type: ignore[arg-type]
)
playlist.append(
f"duration {self.frame_times[t_idx + 1] - self.frame_times[t_idx]}"
)
Path(self.path).parent.mkdir(parents=True, exist_ok=True)
2024-05-27 20:27:01 -06:00
try:
p = sp.run(
self.ffmpeg_cmd.split(" "),
input="\n".join(playlist),
encoding="ascii",
capture_output=True,
)
except BlockingIOError:
logger.warning(
f"Failed to create preview for {self.config.name}, retrying..."
)
time.sleep(2)
p = sp.run(
self.ffmpeg_cmd.split(" "),
input="\n".join(playlist),
encoding="ascii",
capture_output=True,
)
start = self.frame_times[0]
end = self.frame_times[-1]
if p.returncode == 0:
logger.debug("successfully saved preview")
self.requestor.send_data(
INSERT_PREVIEW,
{
2024-07-24 11:29:51 -06:00
Previews.id.name: f"{self.config.name}_{end}",
Previews.camera.name: self.config.name,
Previews.path.name: self.path,
Previews.start_time.name: start,
Previews.end_time.name: end,
Previews.duration.name: end - start,
},
)
else:
logger.error(f"Error saving preview for {self.config.name} :: {p.stderr}")
# unlink files from cache
# don't delete last frame as it will be used as first frame in next segment
for t in self.frame_times[0:-1]:
2026-03-25 12:53:19 -06:00
Path(get_cache_image_name(self.config.name, t)).unlink(missing_ok=True) # type: ignore[arg-type]
class PreviewRecorder:
def __init__(self, config: CameraConfig) -> None:
self.config = config
2026-03-25 12:53:19 -06:00
self.camera_name: str = config.name or ""
self.start_time: float = 0
self.last_output_time: float = 0
2025-03-05 07:07:48 -07:00
self.offline = False
2026-03-25 12:53:19 -06:00
self.output_frames: list[float] = []
2026-03-25 12:53:19 -06:00
if config.detect.width is None or config.detect.height is None:
raise ValueError("Detect width and height must be set for previews.")
self.detect_width: int = config.detect.width
self.detect_height: int = config.detect.height
if self.detect_width > self.detect_height:
self.out_height = PREVIEW_HEIGHT
self.out_width = (
2026-03-25 12:53:19 -06:00
int((self.detect_width / self.detect_height) * self.out_height) // 4 * 4
)
else:
self.out_width = PREVIEW_HEIGHT
self.out_height = (
2026-03-25 12:53:19 -06:00
int((self.detect_height / self.detect_width) * self.out_width) // 4 * 4
)
# create communication for finished previews
self.requestor = InterProcessRequestor()
y, u1, u2, v1, v2 = get_yuv_crop(
self.config.frame_shape_yuv,
(
0,
0,
self.config.frame_shape[1],
self.config.frame_shape[0],
),
)
self.channel_dims = {
"y": y,
"u1": u1,
"u2": u2,
"v1": v1,
"v2": v2,
}
2026-03-29 18:38:11 -06:00
# end segment at end of hour (use UTC to avoid DST issues)
self.segment_end = (
2026-07-06 09:28:02 -08:00
(datetime.datetime.now(datetime.UTC) + datetime.timedelta(hours=1))
.replace(minute=0, second=0, microsecond=0)
.timestamp()
)
2024-03-05 12:56:38 -07:00
Path(PREVIEW_CACHE_DIR).mkdir(exist_ok=True)
Path(os.path.join(CLIPS_DIR, f"previews/{config.name}")).mkdir(
parents=True, exist_ok=True
)
2024-03-05 12:56:38 -07:00
# check for existing items in cache
start_ts = (
2026-07-06 09:28:02 -08:00
datetime.datetime.now(datetime.UTC)
2024-03-05 12:56:38 -07:00
.replace(minute=0, second=0, microsecond=0)
.timestamp()
)
2026-03-23 10:22:52 -06:00
file_start = f"preview_{config.name}-"
start_file = f"{file_start}{start_ts}.webp"
2024-03-05 12:56:38 -07:00
for file in sorted(os.listdir(os.path.join(CACHE_DIR, FOLDER_PREVIEW_FRAMES))):
if not file.startswith(file_start):
continue
if file < start_file:
os.unlink(os.path.join(PREVIEW_CACHE_DIR, file))
continue
try:
file_time = file.split("-")[-1][: -(len(PREVIEW_FRAME_TYPE) + 1)]
if not file_time:
continue
ts = float(file_time)
except ValueError:
continue
2024-03-05 12:56:38 -07:00
if self.start_time == 0:
self.start_time = ts
self.last_output_time = ts
self.output_frames.append(ts)
2025-03-05 07:07:48 -07:00
def reset_frame_cache(self, frame_time: float) -> None:
self.segment_end = (
2026-03-29 18:38:11 -06:00
(
2026-07-06 09:28:02 -08:00
datetime.datetime.fromtimestamp(frame_time, tz=datetime.UTC)
2026-03-29 18:38:11 -06:00
+ datetime.timedelta(hours=1)
)
2025-03-05 07:07:48 -07:00
.replace(minute=0, second=0, microsecond=0)
.timestamp()
)
self.start_time = frame_time
self.last_output_time = frame_time
2026-03-25 12:53:19 -06:00
self.output_frames = []
2025-03-05 07:07:48 -07:00
def should_write_frame(
self,
2025-05-13 16:27:20 +02:00
current_tracked_objects: list[dict[str, Any]],
motion_boxes: list[list[int]],
frame_time: float,
) -> bool:
"""Decide if this frame should be added to PREVIEW."""
if not self.config.record.enabled:
return False
active_objs = get_active_objects(
frame_time, self.config, current_tracked_objects
)
preview_output_fps = 2 if any(o["label"] == "car" for o in active_objs) else 1
# limit output to 1 fps
if (frame_time - self.last_output_time) < 1 / preview_output_fps:
return False
# send frame if a non-stationary object is in a zone
2024-03-08 16:45:42 -07:00
if len(active_objs) > 0:
self.last_output_time = frame_time
return True
if len(motion_boxes) > 0:
self.last_output_time = frame_time
return True
# ensure that at least 2 frames are written every minute
if frame_time - self.last_output_time > 30:
self.last_output_time = frame_time
return True
return False
def write_frame_to_cache(self, frame_time: float, frame: np.ndarray) -> None:
# resize yuv frame
2026-03-25 12:53:19 -06:00
small_frame: np.ndarray = np.zeros(
(self.out_height * 3 // 2, self.out_width), np.uint8
)
copy_yuv_to_position(
small_frame,
(0, 0),
(self.out_height, self.out_width),
frame,
self.channel_dims,
cv2.INTER_AREA,
)
small_frame = cv2.cvtColor(
small_frame,
cv2.COLOR_YUV2BGR_I420,
)
2026-05-01 12:25:26 -05:00
cache_path = get_cache_image_name(self.camera_name, frame_time)
if not cv2.imwrite(
cache_path,
2024-03-11 17:31:05 -06:00
small_frame,
[
int(cv2.IMWRITE_WEBP_QUALITY),
PREVIEW_QUALITY_WEBP[self.config.record.preview.quality],
],
2026-05-01 12:25:26 -05:00
):
logger.error("Failed to write preview frame to %s", cache_path)
def write_data(
self,
2025-05-13 16:27:20 +02:00
current_tracked_objects: list[dict[str, Any]],
motion_boxes: list[list[int]],
frame_time: float,
frame: np.ndarray,
2025-03-05 07:07:48 -07:00
) -> None:
self.offline = False
# always write the first frame
if self.start_time == 0:
self.start_time = frame_time
self.output_frames.append(frame_time)
self.write_frame_to_cache(frame_time, frame)
2025-03-05 07:07:48 -07:00
return
# check if PREVIEW clip should be generated and cached frames reset
if frame_time >= self.segment_end:
if len(self.output_frames) > 0:
# save last frame to ensure consistent duration
if self.config.record:
self.output_frames.append(frame_time)
self.write_frame_to_cache(frame_time, frame)
# write the preview if any frames exist for this hour
FFMpegConverter(
self.config,
self.output_frames,
self.requestor,
).start()
else:
logger.debug(
2026-03-25 12:53:19 -06:00
f"Not saving preview for {self.camera_name} because there are no saved frames."
)
2025-03-05 07:07:48 -07:00
self.reset_frame_cache(frame_time)
# include first frame to ensure consistent duration
if self.config.record.enabled:
self.output_frames.append(frame_time)
self.write_frame_to_cache(frame_time, frame)
2025-03-05 07:07:48 -07:00
return
elif self.should_write_frame(current_tracked_objects, motion_boxes, frame_time):
self.output_frames.append(frame_time)
self.write_frame_to_cache(frame_time, frame)
2025-03-05 07:07:48 -07:00
return
def flag_offline(self, frame_time: float) -> None:
2025-03-05 07:07:48 -07:00
if not self.offline:
self.write_frame_to_cache(
frame_time,
2026-03-25 12:53:19 -06:00
get_blank_yuv_frame(self.detect_width, self.detect_height),
2025-03-05 07:07:48 -07:00
)
self.offline = True
# check if PREVIEW clip should be generated and cached frames reset
if frame_time >= self.segment_end:
if len(self.output_frames) == 0:
2025-03-05 07:07:48 -07:00
# camera has been offline for entire hour
# we have no preview to create
self.reset_frame_cache(frame_time)
return
old_frame_path = get_cache_image_name(
2026-03-25 12:53:19 -06:00
self.camera_name, self.output_frames[-1]
)
2026-03-25 12:53:19 -06:00
new_frame_path = get_cache_image_name(self.camera_name, frame_time)
shutil.copy(old_frame_path, new_frame_path)
# save last frame to ensure consistent duration
self.output_frames.append(frame_time)
FFMpegConverter(
self.config,
self.output_frames,
self.requestor,
).start()
2025-03-05 07:07:48 -07:00
self.reset_frame_cache(frame_time)
def stop(self) -> None:
self.requestor.stop()
def get_active_objects(
2026-03-25 12:53:19 -06:00
frame_time: float, camera_config: CameraConfig, all_objects: list[dict[str, Any]]
) -> list[dict[str, Any]]:
"""get active objects for detection."""
return [
o
for o in all_objects
if o["motionless_count"] < camera_config.detect.stationary.threshold
and o["position_changes"] > 0
and o["frame_time"] == frame_time
and not o["false_positive"]
]