Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
c7db0c0706
Merge 92891d255e into 77831304a7 2026-04-23 19:36:18 +01:00
48 changed files with 211 additions and 2413 deletions

View File

@ -26,7 +26,7 @@ _Please read the [contributing guidelines](https://github.com/blakeblackshear/fr
- This PR fixes or closes issue: fixes #
- This PR is related to issue:
- Link to discussion with maintainers (**required** for any large or "planned" features):
- Link to discussion with maintainers (**required** for large/pinned features):
## For new features

View File

@ -19,8 +19,8 @@ jobs:
days-before-stale: 30
days-before-close: 3
exempt-draft-pr: true
exempt-issue-labels: "planned,security"
exempt-pr-labels: "planned,security,dependencies"
exempt-issue-labels: "pinned,security"
exempt-pr-labels: "pinned,security,dependencies"
operations-per-run: 120
- name: Print outputs
env:

View File

@ -12,7 +12,7 @@ If you've found a bug and want to fix it, go for it. Link to the relevant issue
Every new feature adds scope that the maintainers must test, maintain, and support long-term. Before writing code for a new feature:
1. **Check for existing discussion.** Search [feature requests](https://github.com/blakeblackshear/frigate/issues) and [discussions](https://github.com/blakeblackshear/frigate/discussions) to see if it's been proposed or discussed. Feature requests tagged with "planned" are on our radar — we plan to get to them, but we don't maintain a public roadmap or timeline. Check in with us first if you have interest in contributing to one.
1. **Check for existing discussion.** Search [feature requests](https://github.com/blakeblackshear/frigate/issues) and [discussions](https://github.com/blakeblackshear/frigate/discussions) to see if it's been proposed or discussed. Pinned feature requests are on our radar — we plan to get to them, but we don't maintain a public roadmap or timeline. Check in with us first if you have interest in contributing to one.
2. **Start a discussion or feature request first.** This helps ensure your idea aligns with Frigate's direction before you invest time building it. Community interest in a feature request helps us gauge demand, though a great idea is a great idea even without a crowd behind it.
3. **Be open to "no".** We try to be thoughtful about what we take on, and sometimes that means saying no to good code if the feature isn't the right fit for the project. These calls are sometimes subjective, and we won't always get them right. We're happy to discuss and reconsider.

View File

@ -32,14 +32,11 @@ RUN echo /opt/rocm/lib|tee /opt/rocm-dist/etc/ld.so.conf.d/rocm.conf
FROM deps AS deps-prelim
COPY docker/rocm/debian-backports.sources /etc/apt/sources.list.d/debian-backports.sources
# install_deps.sh upgraded libstdc++6 from trixie for Battlemage; the matching
# -dev package must also come from trixie or apt refuses to satisfy it.
RUN echo "deb http://deb.debian.org/debian trixie main" > /etc/apt/sources.list.d/trixie.list && \
apt-get update && \
RUN apt-get update && \
apt-get install -y libnuma1 && \
apt-get install -qq -y -t bookworm-backports mesa-va-drivers mesa-vulkan-drivers && \
apt-get install -qq -y -t trixie libstdc++-14-dev && \
rm -f /etc/apt/sources.list.d/trixie.list && \
# Install C++ standard library headers for HIPRTC kernel compilation fallback
apt-get install -qq -y libstdc++-12-dev && \
rm -rf /var/lib/apt/lists/*
WORKDIR /opt/frigate

View File

@ -171,7 +171,7 @@ When choosing images to include in the face training set it is recommended to al
- If it is difficult to make out details in a persons face it will not be helpful in training.
- Avoid images with extreme under/over-exposure.
- Avoid blurry / pixelated images.
- Avoid training on infrared (gray-scale). The models are trained on color images and will not be able to extract features from gray-scale images.
- Avoid training on infrared (gray-scale). The models are trained on color images and will be able to extract features from gray-scale images.
- Using images of people wearing hats / sunglasses may confuse the model.
- Do not upload too many similar images at the same time, it is recommended to train no more than 4-6 similar images for each person to avoid over-fitting.

View File

@ -36,7 +36,6 @@ from frigate.api.defs.response.chat_response import (
)
from frigate.api.defs.tags import Tags
from frigate.api.event import events
from frigate.config import FrigateConfig
from frigate.genai.utils import build_assistant_message_for_conversation
from frigate.jobs.vlm_watch import (
get_vlm_watch_job,
@ -402,38 +401,9 @@ def get_tools() -> JSONResponse:
return JSONResponse(content={"tools": tools})
def _resolve_zones(
zones: List[str],
config: FrigateConfig,
target_cameras: List[str],
) -> List[str]:
"""Map zone names to their canonical config keys, case-insensitively.
LLMs frequently echo a user's casing ("Front Yard") instead of the
configured key ("front_yard"). The downstream zone filter is a SQLite GLOB
over the JSON-encoded zones column, which is case-sensitive so an
unnormalized name silently returns zero matches. Build a lookup over the
relevant cameras' configured zones and substitute when we find a match;
unknown names pass through so behavior matches what the model asked for.
"""
if not zones:
return zones
lookup: Dict[str, str] = {}
for camera_id in target_cameras:
camera_config = config.cameras.get(camera_id)
if camera_config is None:
continue
for zone_name in camera_config.zones.keys():
lookup.setdefault(zone_name.lower(), zone_name)
return [lookup.get(z.lower(), z) for z in zones]
async def _execute_search_objects(
arguments: Dict[str, Any],
allowed_cameras: List[str],
config: FrigateConfig,
) -> JSONResponse:
"""
Execute the search_objects tool.
@ -467,11 +437,6 @@ async def _execute_search_objects(
# Convert zones array to comma-separated string if provided
zones = arguments.get("zones")
if isinstance(zones, list):
camera_arg = arguments.get("camera")
target_cameras = (
[camera_arg] if camera_arg and camera_arg != "all" else allowed_cameras
)
zones = _resolve_zones(zones, config, target_cameras)
zones = ",".join(zones)
elif zones is None:
zones = "all"
@ -563,11 +528,6 @@ async def _execute_find_similar_objects(
sub_labels = arguments.get("sub_labels")
zones = arguments.get("zones")
if zones:
zones = _resolve_zones(
zones, request.app.frigate_config, cameras or list(allowed_cameras)
)
similarity_mode = arguments.get("similarity_mode", "fused")
if similarity_mode not in ("visual", "semantic", "fused"):
similarity_mode = "fused"
@ -695,9 +655,7 @@ async def execute_tool(
logger.debug(f"Executing tool: {tool_name} with arguments: {arguments}")
if tool_name == "search_objects":
return await _execute_search_objects(
arguments, allowed_cameras, request.app.frigate_config
)
return await _execute_search_objects(arguments, allowed_cameras)
if tool_name == "find_similar_objects":
result = await _execute_find_similar_objects(
@ -877,9 +835,7 @@ async def _execute_tool_internal(
This is used by the chat completion endpoint to execute tools.
"""
if tool_name == "search_objects":
response = await _execute_search_objects(
arguments, allowed_cameras, request.app.frigate_config
)
response = await _execute_search_objects(arguments, allowed_cameras)
try:
if hasattr(response, "body"):
body_str = response.body.decode("utf-8")
@ -943,9 +899,6 @@ async def _execute_start_camera_watch(
await require_camera_access(camera, request=request)
if zones:
zones = _resolve_zones(zones, config, [camera])
genai_manager = request.app.genai_manager
chat_client = genai_manager.chat_client
if chat_client is None or not chat_client.supports_vision:

View File

@ -5,15 +5,13 @@ import logging
import random
import string
import time
import zipfile
from collections import deque
from pathlib import Path
from typing import Iterator, List, Optional
from typing import List, Optional
import psutil
from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import JSONResponse, StreamingResponse
from pathvalidate import sanitize_filename, sanitize_filepath
from fastapi.responses import JSONResponse
from pathvalidate import sanitize_filepath
from peewee import DoesNotExist
from playhouse.shortcuts import model_to_dict
@ -363,136 +361,6 @@ def get_export_case(case_id: str):
)
_ZIP_STREAM_CHUNK_SIZE = 1024 * 1024 # 1 MiB
class _StreamingZipBuffer:
"""File-like sink for ZipFile that exposes written bytes via drain().
ZipFile writes synchronously into this buffer; the generator drains the
queue between writes so StreamingResponse can yield bytes without
materializing the whole archive in memory.
"""
def __init__(self) -> None:
self._queue: deque[bytes] = deque()
self._offset = 0
def write(self, data: bytes) -> int:
if data:
self._queue.append(bytes(data))
self._offset += len(data)
return len(data)
def tell(self) -> int:
return self._offset
def flush(self) -> None:
pass
def drain(self) -> Iterator[bytes]:
while self._queue:
yield self._queue.popleft()
def _unique_archive_name(export: Export, used: set[str]) -> str:
base = sanitize_filename(export.name) if export.name else None
if not base:
base = f"{export.camera}_{int(datetime.datetime.timestamp(export.date))}"
candidate = f"{base}.mp4"
counter = 1
while candidate in used:
candidate = f"{base}_{counter}.mp4"
counter += 1
used.add(candidate)
return candidate
def _stream_case_archive(exports: List[Export]) -> Iterator[bytes]:
"""Yield bytes of a zip archive built from the given exports' mp4 files."""
buffer = _StreamingZipBuffer()
used_names: set[str] = set()
# ZIP_STORED: mp4 is already compressed, recompressing wastes CPU for ~0% size win.
with zipfile.ZipFile(
buffer,
mode="w",
compression=zipfile.ZIP_STORED,
allowZip64=True,
) as archive:
for export in exports:
source = Path(export.video_path)
if not source.exists():
continue
arcname = _unique_archive_name(export, used_names)
with (
archive.open(arcname, mode="w", force_zip64=True) as entry,
source.open("rb") as src,
):
while True:
chunk = src.read(_ZIP_STREAM_CHUNK_SIZE)
if not chunk:
break
entry.write(chunk)
yield from buffer.drain()
yield from buffer.drain()
yield from buffer.drain()
@router.get(
"/cases/{case_id}/download",
dependencies=[Depends(allow_any_authenticated())],
summary="Download export case as zip",
description="Streams a zip archive containing every completed export's mp4 for the given case.",
)
def download_export_case(
case_id: str,
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
try:
case = ExportCase.get(ExportCase.id == case_id)
except DoesNotExist:
return JSONResponse(
content={"success": False, "message": "Export case not found"},
status_code=404,
)
exports = list(
Export.select()
.where(
Export.export_case == case_id,
~Export.in_progress,
Export.camera << allowed_cameras,
)
.order_by(Export.date.asc())
)
if not exports:
return JSONResponse(
content={"success": False, "message": "No exports available to download."},
status_code=404,
)
archive_base = sanitize_filename(case.name) if case.name else ""
if not archive_base:
archive_base = case_id
return StreamingResponse(
_stream_case_archive(exports),
media_type="application/zip",
headers={
"Content-Disposition": f'attachment; filename="{archive_base}.zip"',
},
)
@router.patch(
"/cases/{case_id}",
response_model=GenericResponse,

View File

@ -1368,17 +1368,12 @@ def preview_gif(
file_start = f"preview_{camera_name}-"
start_file = f"{file_start}{start_ts}.{PREVIEW_FRAME_TYPE}"
end_file = f"{file_start}{end_ts}.{PREVIEW_FRAME_TYPE}"
camera_files = [
entry.name
for entry in os.scandir(preview_dir)
if entry.name.startswith(file_start)
]
camera_files.sort()
selected_previews = []
for file in camera_files:
for file in sorted(os.listdir(preview_dir)):
if not file.startswith(file_start):
continue
if file < start_file:
continue
@ -1555,17 +1550,12 @@ def preview_mp4(
file_start = f"preview_{camera_name}-"
start_file = f"{file_start}{start_ts}.{PREVIEW_FRAME_TYPE}"
end_file = f"{file_start}{end_ts}.{PREVIEW_FRAME_TYPE}"
camera_files = [
entry.name
for entry in os.scandir(preview_dir)
if entry.name.startswith(file_start)
]
camera_files.sort()
selected_previews = []
for file in camera_files:
for file in sorted(os.listdir(preview_dir)):
if not file.startswith(file_start):
continue
if file < start_file:
continue

View File

@ -148,17 +148,12 @@ def get_preview_frames_from_cache(camera_name: str, start_ts: float, end_ts: flo
file_start = f"preview_{camera_name}-"
start_file = f"{file_start}{start_ts}.{PREVIEW_FRAME_TYPE}"
end_file = f"{file_start}{end_ts}.{PREVIEW_FRAME_TYPE}"
camera_files = [
entry.name
for entry in os.scandir(preview_dir)
if entry.name.startswith(file_start)
]
camera_files.sort()
selected_previews = []
for file in camera_files:
for file in sorted(os.listdir(preview_dir)):
if not file.startswith(file_start):
continue
if file < start_file:
continue

View File

@ -20,7 +20,6 @@ class CameraConfigUpdateEnum(str, Enum):
ffmpeg = "ffmpeg"
live = "live"
motion = "motion" # includes motion and motion masks
mqtt = "mqtt"
notifications = "notifications"
objects = "objects"
object_genai = "object_genai"
@ -34,7 +33,6 @@ class CameraConfigUpdateEnum(str, Enum):
lpr = "lpr"
snapshots = "snapshots"
timestamp_style = "timestamp_style"
ui = "ui"
zones = "zones"

View File

@ -15,7 +15,7 @@ TRIGGER_DIR = f"{CLIPS_DIR}/triggers"
BIRDSEYE_PIPE = "/tmp/cache/birdseye"
CACHE_DIR = "/tmp/cache"
REPLAY_CAMERA_PREFIX = "_replay_"
REPLAY_DIR = os.path.join(CLIPS_DIR, "replay")
REPLAY_DIR = os.path.join(CACHE_DIR, "replay")
PLUS_ENV_VAR = "PLUS_API_KEY"
PLUS_API_HOST = "https://api.frigate.video"

View File

@ -133,61 +133,6 @@ class FaceRecognizer(ABC):
return 0.0
def build_class_mean(
embs: list[np.ndarray],
trim: float = 0.15,
outlier_threshold: float = 0.30,
min_keep_frac: float = 0.7,
max_iters: int = 3,
) -> np.ndarray:
"""Build a class-mean embedding with two-layer outlier protection.
Layer 1 (iterative, vector-wise): drop whole embeddings whose cosine
similarity to the current class mean is below ``outlier_threshold``.
Catches mislabeled or corrupted training samples (wrong face in the
folder, full-frame screenshots, extreme crops) that per-dimension
trimming cannot detect.
Layer 2 (per-dimension): ``scipy.stats.trim_mean`` on the retained set
to smooth per-component noise (lighting, expression, alignment jitter).
Collections with fewer than 5 images bypass outlier rejection too few
samples to establish a reliable class center.
"""
arr = np.stack(embs, axis=0)
if len(arr) < 5:
return np.asarray(stats.trim_mean(arr, trim, axis=0))
keep = np.ones(len(arr), dtype=bool)
floor = max(5, int(np.ceil(min_keep_frac * len(arr))))
for _ in range(max_iters):
mean = stats.trim_mean(arr[keep], trim, axis=0)
m_norm = mean / (np.linalg.norm(mean) + 1e-9)
e_norms = arr / (np.linalg.norm(arr, axis=1, keepdims=True) + 1e-9)
cos = e_norms @ m_norm
new_keep = cos >= outlier_threshold
if new_keep.sum() < floor:
top = np.argsort(-cos)[:floor]
new_keep = np.zeros(len(arr), dtype=bool)
new_keep[top] = True
if np.array_equal(new_keep, keep):
break
keep = new_keep
dropped = int((~keep).sum())
if dropped:
logger.debug(
f"Vector-wise outlier filter dropped {dropped}/{len(arr)} embeddings"
)
return np.asarray(stats.trim_mean(arr[keep], trim, axis=0))
def similarity_to_confidence(
cosine_similarity: float,
median: float = 0.3,
@ -284,7 +229,7 @@ class FaceNetRecognizer(FaceRecognizer):
for name, embs in face_embeddings_map.items():
if embs:
self.mean_embs[name] = build_class_mean(embs)
self.mean_embs[name] = stats.trim_mean(embs, 0.15)
logger.debug("Finished building ArcFace model")
@ -395,7 +340,7 @@ class ArcFaceRecognizer(FaceRecognizer):
for name, embs in face_embeddings_map.items():
if embs:
self.mean_embs[name] = build_class_mean(embs)
self.mean_embs[name] = stats.trim_mean(embs, 0.15)
logger.debug("Finished building ArcFace model")

View File

@ -39,8 +39,6 @@ logger = logging.getLogger(__name__)
RECORDING_BUFFER_EXTENSION_PERCENT = 0.10
MIN_RECORDING_DURATION = 10
MAX_IMAGE_TOKENS = 24000
MAX_FRAMES_PER_SECOND = 1
class ReviewDescriptionProcessor(PostProcessorApi):
@ -62,22 +60,14 @@ class ReviewDescriptionProcessor(PostProcessorApi):
def calculate_frame_count(
self,
camera: str,
duration: float,
image_source: ImageSourceEnum = ImageSourceEnum.preview,
height: int = 480,
) -> int:
"""Calculate optimal number of frames based on event duration, context size,
image source, and resolution.
"""Calculate optimal number of frames based on context size, image source, and resolution.
Per-image token cost is asked of the GenAI provider so providers that know
their model's true cost (e.g. llama.cpp can probe the loaded mmproj) can
diverge from the default ~1-token-per-1250-pixels heuristic. The frame
budget is bounded by:
- remaining context window after prompt + response reservations
- a fixed MAX_IMAGE_TOKENS ceiling
- MAX_FRAMES_PER_SECOND x duration, to avoid drowning short events in
near-duplicate frames where the model latches onto the redundant middle
and skips the start/end action
Token usage varies by resolution: larger images (ultra-wide aspect ratios) use more tokens.
Estimates ~1 token per 1250 pixels. Targets 98% context utilization with safety margin.
Capped at 20 frames.
"""
client = self.genai_manager.description_client
@ -115,15 +105,14 @@ class ReviewDescriptionProcessor(PostProcessorApi):
width = target_width
height = int(target_width / aspect_ratio)
tokens_per_image = client.estimate_image_tokens(width, height)
pixels_per_image = width * height
tokens_per_image = pixels_per_image / 1250
prompt_tokens = 3800
response_tokens = 300
context_budget = context_size - prompt_tokens - response_tokens
image_token_budget = min(context_budget, MAX_IMAGE_TOKENS)
max_frames_by_tokens = int(image_token_budget / tokens_per_image)
max_frames_by_duration = int(duration * MAX_FRAMES_PER_SECOND)
max_frames = min(max_frames_by_tokens, max_frames_by_duration)
return max(max_frames, 3)
available_tokens = context_size - prompt_tokens - response_tokens
max_frames = int(available_tokens / tokens_per_image)
return min(max(max_frames, 3), 20)
def process_data(
self, data: dict[str, Any], data_type: PostProcessDataEnum
@ -366,17 +355,12 @@ class ReviewDescriptionProcessor(PostProcessorApi):
file_start = f"preview_{camera}-"
start_file = f"{file_start}{start_time}.webp"
end_file = f"{file_start}{end_time}.webp"
camera_files = [
entry.name
for entry in os.scandir(preview_dir)
if entry.name.startswith(file_start)
]
camera_files.sort()
all_frames: list[str] = []
for file in camera_files:
for file in sorted(os.listdir(preview_dir)):
if not file.startswith(file_start):
continue
if file < start_file:
if len(all_frames):
all_frames[0] = os.path.join(preview_dir, file)
@ -392,9 +376,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
all_frames.append(os.path.join(preview_dir, file))
frame_count = len(all_frames)
desired_frame_count = self.calculate_frame_count(
camera, duration=end_time - start_time
)
desired_frame_count = self.calculate_frame_count(camera)
if frame_count <= desired_frame_count:
return all_frames
@ -418,7 +400,7 @@ class ReviewDescriptionProcessor(PostProcessorApi):
"""Get frames from recordings at specified timestamps."""
duration = end_time - start_time
desired_frame_count = self.calculate_frame_count(
camera, duration, ImageSourceEnum.recordings, height
camera, ImageSourceEnum.recordings, height
)
# Calculate evenly spaced timestamps throughout the duration

View File

@ -1,48 +1,21 @@
from typing import Annotated
from pydantic import BaseModel, ConfigDict, Field, StringConstraints
ObservationItem = Annotated[str, StringConstraints(min_length=20, max_length=160)]
from pydantic import BaseModel, ConfigDict, Field
class ReviewMetadata(BaseModel):
model_config = ConfigDict(extra="ignore", protected_namespaces=())
observations: list[ObservationItem] = Field(
...,
min_length=3,
max_length=15,
description=(
"Enumerate the significant observations across all frames, in "
"chronological order, BEFORE composing the scene narrative. "
"Include the very start of the activity — for example, a vehicle "
"entering the frame or pulling into the driveway — even if it "
"lasts only a few frames and the rest of the clip is dominated "
"by a longer activity. Include each arrival, departure, motion "
"event, object handled, and notable change in position or state. "
"Each item is a single concrete fact written as a complete "
"sentence. Do not summarize, interpret, or assign meaning here — "
"that belongs in the scene field."
),
)
title: str = Field(
max_length=80,
description="Under 10 words. Name the apparent purpose or outcome of the activity together with the location involved. Do not narrate or list the sequence of actions step by step.",
description="A short title characterizing what took place and where, under 10 words."
)
scene: str = Field(
min_length=150,
max_length=600,
description="A chronological narrative of what happens from start to finish, drawing directly from the items in observations.",
description="A chronological narrative of what happens from start to finish."
)
shortSummary: str = Field(
min_length=70,
max_length=120,
description="A brief 2-sentence summary of the scene, suitable for notifications.",
description="A brief 2-sentence summary of the scene, suitable for notifications."
)
confidence: float = Field(
ge=0.0,
le=1.0,
description="Confidence in the analysis as a decimal between 0.0 and 1.0, where 0.0 means no confidence and 1.0 means complete confidence. Express ONLY as a decimal.",
description="Confidence in the analysis, from 0 to 1.",
)
potential_threat_level: int = Field(
ge=0,

View File

@ -4,7 +4,6 @@ import base64
import json
import logging
import os
import sys
import threading
from json.decoder import JSONDecodeError
from multiprocessing.synchronize import Event as MpEvent
@ -53,14 +52,6 @@ class EmbeddingProcess(FrigateProcess):
self.stop_event,
)
maintainer.start()
maintainer.join()
# If the maintainer thread exited but no shutdown was requested, it
# crashed. Surface as a non-zero exit so the watchdog restarts us
# instead of treating the silent thread death as a clean shutdown.
if not self.stop_event.is_set():
logger.error("Embeddings maintainer thread exited unexpectedly")
sys.exit(1)
class EmbeddingsContext:

View File

@ -517,16 +517,10 @@ class EmbeddingMaintainer(threading.Thread):
try:
event: Event = Event.get(Event.id == event_id)
except DoesNotExist:
for processor in self.post_processors:
if isinstance(processor, ObjectDescriptionProcessor):
processor.cleanup_event(event_id)
continue
# Skip the event if not an object
if event.data.get("type") != "object":
for processor in self.post_processors:
if isinstance(processor, ObjectDescriptionProcessor):
processor.cleanup_event(event_id)
continue
# Extract valid thumbnail

View File

@ -205,7 +205,6 @@ class AudioEventMaintainer(threading.Thread):
self.transcription_thread.start()
self.was_enabled = camera.enabled
self.was_audio_enabled = camera.audio.enabled
def detect_audio(self, audio: np.ndarray) -> None:
if not self.camera_config.audio.enabled or self.stop_event.is_set():
@ -364,17 +363,6 @@ class AudioEventMaintainer(threading.Thread):
time.sleep(0.1)
continue
audio_enabled = self.camera_config.audio.enabled
if audio_enabled != self.was_audio_enabled:
if not audio_enabled:
self.logger.debug(
f"Disabling audio detections for {self.camera_config.name}, ending events"
)
self.requestor.send_data(
EXPIRE_AUDIO_ACTIVITY, self.camera_config.name
)
self.was_audio_enabled = audio_enabled
self.read_audio()
if self.audio_listener:

View File

@ -2,7 +2,6 @@
import datetime
import importlib
import json
import logging
import os
import re
@ -10,7 +9,6 @@ from typing import Any, Callable, Optional
import numpy as np
from playhouse.shortcuts import model_to_dict
from pydantic import ValidationError
from frigate.config import CameraConfig, GenAIConfig, GenAIProviderEnum
from frigate.const import CLIPS_DIR
@ -153,6 +151,9 @@ Each line represents a detection state, not necessarily unique individuals. The
if "other_concerns" in schema.get("required", []):
schema["required"].remove("other_concerns")
# OpenAI strict mode requires additionalProperties: false on all objects
schema["additionalProperties"] = False
response_format = {
"type": "json_schema",
"json_schema": {
@ -180,36 +181,7 @@ Each line represents a detection state, not necessarily unique individuals. The
try:
metadata = ReviewMetadata.model_validate_json(clean_json)
except ValidationError as ve:
# Constraint violations (length, item count, ranges) are logged
# at debug and the response is kept anyway — a slightly
# off-spec answer is still usable, and dropping the whole
# response loses the narrative content the model produced.
for err in ve.errors():
loc = ".".join(str(p) for p in err["loc"]) or "<root>"
logger.debug(
"Review metadata soft validation: %s%s (input: %r)",
loc,
err["msg"],
err.get("input"),
)
try:
raw = json.loads(clean_json)
except json.JSONDecodeError as je:
logger.error("Failed to parse review description JSON: %s", je)
return None
# observations and confidence are required on the model; fill an empty default
# if the response omitted it so attribute access stays safe.
raw.setdefault("observations", [])
raw.setdefault("confidence", 0.0)
metadata = ReviewMetadata.model_construct(**raw)
except Exception as e:
logger.error(
f"Failed to parse review description as the response did not match expected format. {e}"
)
return None
try:
# Normalize confidence if model returned a percentage (e.g. 85 instead of 0.85)
if metadata.confidence > 1.0:
metadata.confidence = min(metadata.confidence / 100.0, 1.0)
@ -222,7 +194,10 @@ Each line represents a detection state, not necessarily unique individuals. The
metadata.time = review_data["start"]
return metadata
except Exception as e:
logger.error(f"Failed to post-process review metadata: {e}")
# rarely LLMs can fail to follow directions on output format
logger.warning(
f"Failed to parse review description as the response did not match expected format. {e}"
)
return None
else:
logger.debug(
@ -369,14 +344,6 @@ Guidelines:
"""Get the context window size for this provider in tokens."""
return 4096
def estimate_image_tokens(self, width: int, height: int) -> float:
"""Estimate prompt tokens consumed by a single image of the given dimensions.
Default heuristic: ~1 token per 1250 pixels. Providers that can measure or
know their model's exact image-token cost should override.
"""
return (width * height) / 1250
def embed(
self,
texts: list[str] | None = None,

View File

@ -136,44 +136,22 @@ class GeminiClient(GenAIClient):
)
)
elif role == "assistant":
parts: list[types.Part] = []
if content:
parts.append(types.Part.from_text(text=content))
for tc in msg.get("tool_calls") or []:
func = tc.get("function") or {}
tc_name = func.get("name") or ""
tc_args: Any = func.get("arguments")
if isinstance(tc_args, str):
try:
tc_args = json.loads(tc_args)
except (json.JSONDecodeError, TypeError):
tc_args = {}
if not isinstance(tc_args, dict):
tc_args = {}
if tc_name:
parts.append(
types.Part.from_function_call(
name=tc_name, args=tc_args
)
)
if not parts:
parts.append(types.Part.from_text(text=" "))
gemini_messages.append(types.Content(role="model", parts=parts))
gemini_messages.append(
types.Content(
role="model", parts=[types.Part.from_text(text=content)]
)
)
elif role == "tool":
# Handle tool response
response_payload = (
content if isinstance(content, dict) else {"result": content}
)
function_response = {
"name": msg.get("name", ""),
"response": content,
}
gemini_messages.append(
types.Content(
role="function",
parts=[
types.Part.from_function_response(
name=msg.get("name")
or msg.get("tool_call_id")
or "",
response=response_payload,
)
types.Part.from_function_response(function_response) # type: ignore[misc,call-arg,arg-type]
],
)
)
@ -365,44 +343,22 @@ class GeminiClient(GenAIClient):
)
)
elif role == "assistant":
parts: list[types.Part] = []
if content:
parts.append(types.Part.from_text(text=content))
for tc in msg.get("tool_calls") or []:
func = tc.get("function") or {}
tc_name = func.get("name") or ""
tc_args: Any = func.get("arguments")
if isinstance(tc_args, str):
try:
tc_args = json.loads(tc_args)
except (json.JSONDecodeError, TypeError):
tc_args = {}
if not isinstance(tc_args, dict):
tc_args = {}
if tc_name:
parts.append(
types.Part.from_function_call(
name=tc_name, args=tc_args
)
)
if not parts:
parts.append(types.Part.from_text(text=" "))
gemini_messages.append(types.Content(role="model", parts=parts))
gemini_messages.append(
types.Content(
role="model", parts=[types.Part.from_text(text=content)]
)
)
elif role == "tool":
# Handle tool response
response_payload = (
content if isinstance(content, dict) else {"result": content}
)
function_response = {
"name": msg.get("name", ""),
"response": content,
}
gemini_messages.append(
types.Content(
role="function",
parts=[
types.Part.from_function_response(
name=msg.get("name")
or msg.get("tool_call_id")
or "",
response=response_payload,
)
types.Part.from_function_response(function_response) # type: ignore[misc,call-arg,arg-type]
],
)
)

View File

@ -42,9 +42,6 @@ class LlamaCppClient(GenAIClient):
_supports_vision: bool
_supports_audio: bool
_supports_tools: bool
_image_token_cache: dict[tuple[int, int], int]
_text_baseline_tokens: int | None
_media_marker: str
def _init_provider(self) -> str | None:
"""Initialize the client and query model metadata from the server."""
@ -55,9 +52,6 @@ class LlamaCppClient(GenAIClient):
self._supports_vision = False
self._supports_audio = False
self._supports_tools = False
self._image_token_cache = {}
self._text_baseline_tokens = None
self._media_marker = "<__media__>"
base_url = (
self.genai_config.base_url.rstrip("/")
@ -143,13 +137,6 @@ class LlamaCppClient(GenAIClient):
chat_caps = props.get("chat_template_caps", {})
self._supports_tools = chat_caps.get("supports_tools", False)
# Media marker for multimodal embeddings; the server randomizes this
# per startup unless LLAMA_MEDIA_MARKER is set, so we must read it
# from /props rather than hardcoding "<__media__>".
media_marker = props.get("media_marker")
if isinstance(media_marker, str) and media_marker:
self._media_marker = media_marker
logger.info(
"llama.cpp model '%s' initialized — context: %s, vision: %s, audio: %s, tools: %s",
configured_model,
@ -285,91 +272,6 @@ class LlamaCppClient(GenAIClient):
return self._context_size
return 4096
def estimate_image_tokens(self, width: int, height: int) -> float:
"""Probe the llama.cpp server to learn the model's image-token cost at the
requested dimensions.
llama.cpp's image tokenization is a deterministic function of dimensions and
the loaded mmproj, so the result is cached per (width, height) for the
lifetime of the process. Falls back to the base pixel heuristic if the
server is unreachable or the response is malformed.
"""
if self.provider is None:
return super().estimate_image_tokens(width, height)
cached = self._image_token_cache.get((width, height))
if cached is not None:
return cached
try:
baseline = self._probe_baseline_tokens()
with_image = self._probe_image_prompt_tokens(width, height)
tokens = max(1, with_image - baseline)
except Exception as e:
logger.debug(
"llama.cpp image-token probe failed for %dx%d (%s); using heuristic",
width,
height,
e,
)
return super().estimate_image_tokens(width, height)
self._image_token_cache[(width, height)] = tokens
logger.debug(
"llama.cpp model '%s' uses ~%d tokens for %dx%d images",
self.genai_config.model,
tokens,
width,
height,
)
return tokens
def _probe_baseline_tokens(self) -> int:
"""Return prompt_tokens for a minimal text-only request. Cached after first call."""
if self._text_baseline_tokens is not None:
return self._text_baseline_tokens
self._text_baseline_tokens = self._probe_prompt_tokens(
[{"type": "text", "text": "."}]
)
return self._text_baseline_tokens
def _probe_image_prompt_tokens(self, width: int, height: int) -> int:
"""Return prompt_tokens for a single synthetic image plus minimal text."""
img = Image.new("RGB", (width, height), (128, 128, 128))
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=60)
encoded = base64.b64encode(buf.getvalue()).decode("utf-8")
return self._probe_prompt_tokens(
[
{"type": "text", "text": "."},
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{encoded}"},
},
]
)
def _probe_prompt_tokens(self, content: list[dict[str, Any]]) -> int:
"""POST a 1-token chat completion and return reported prompt_tokens.
Uses a generous timeout to absorb a cold model load on the first probe
when the server lazily loads models on demand (e.g. llama-swap).
"""
payload = {
"model": self.genai_config.model,
"messages": [{"role": "user", "content": content}],
"max_tokens": 1,
}
response = requests.post(
f"{self.provider}/v1/chat/completions",
json=payload,
timeout=60,
)
response.raise_for_status()
return int(response.json()["usage"]["prompt_tokens"])
def _build_payload(
self,
messages: list[dict[str, Any]],
@ -474,11 +376,10 @@ class LlamaCppClient(GenAIClient):
jpeg_bytes = _to_jpeg(img)
to_encode = jpeg_bytes if jpeg_bytes is not None else img
encoded = base64.b64encode(to_encode).decode("utf-8")
# prompt_string must contain the server's media marker placeholder.
# The marker is randomized per server startup (read from /props).
# prompt_string must contain <__media__> placeholder for image tokenization
content.append(
{
"prompt_string": f"{self._media_marker}\n",
"prompt_string": "<__media__>\n",
"multimodal_data": [encoded], # type: ignore[dict-item]
}
)

View File

@ -73,17 +73,8 @@ class OpenAIClient(GenAIClient):
**self.genai_config.runtime_options,
}
if response_format:
# OpenAI strict mode requires additionalProperties: false on the schema
if response_format.get("type") == "json_schema" and response_format.get(
"json_schema", {}
).get("strict"):
schema = response_format.get("json_schema", {}).get("schema")
if isinstance(schema, dict):
schema["additionalProperties"] = False
request_params["response_format"] = response_format
result = self.provider.chat.completions.create(**request_params)
if (
result is not None
and hasattr(result, "choices")

View File

@ -24,7 +24,7 @@ from frigate.config.camera.updater import (
)
from frigate.const import PROCESS_PRIORITY_HIGH
from frigate.log import LogPipe
from frigate.util.builtin import EventsPerSecond, get_ffmpeg_arg_list
from frigate.util.builtin import EventsPerSecond
from frigate.util.ffmpeg import start_or_restart_ffmpeg, stop_ffmpeg
from frigate.util.image import (
FrameManager,
@ -34,23 +34,6 @@ from frigate.util.process import FrigateProcess
logger = logging.getLogger(__name__)
# all built-in record presets use this segment_time
DEFAULT_RECORD_SEGMENT_TIME = 10
def _get_record_segment_time(config: CameraConfig) -> int:
"""Extract -segment_time from the camera's record output args."""
record_args = get_ffmpeg_arg_list(config.ffmpeg.output_args.record)
if record_args and record_args[0].startswith("preset"):
return DEFAULT_RECORD_SEGMENT_TIME
try:
idx = record_args.index("-segment_time")
return int(record_args[idx + 1])
except (ValueError, IndexError):
return DEFAULT_RECORD_SEGMENT_TIME
def capture_frames(
ffmpeg_process: sp.Popen[Any],
@ -181,12 +164,6 @@ class CameraWatchdog(threading.Thread):
self.latest_cache_segment_time: float = 0
self.record_enable_time: datetime | None = None
# `valid` segments are published with the segment's start time, so the
# gap between consecutive publishes can reach 2 * segment_time. Pad the
# staleness threshold so it's never tighter than that worst case.
segment_time = _get_record_segment_time(self.config)
self.record_stale_threshold = max(120, 2 * segment_time + 30)
# Stall tracking (based on last processed frame)
self._stall_timestamps: deque[float] = deque()
self._stall_active: bool = False
@ -340,16 +317,16 @@ class CameraWatchdog(threading.Thread):
if camera != self.config.name:
continue
if topic.endswith(RecordingsDataTypeEnum.invalid.value):
self.logger.warning(
f"Invalid recording segment detected for {camera} at {segment_time}"
)
self.latest_invalid_segment_time = segment_time
elif topic.endswith(RecordingsDataTypeEnum.valid.value):
if topic.endswith(RecordingsDataTypeEnum.valid.value):
self.logger.debug(
f"Latest valid recording segment time on {camera}: {segment_time}"
)
self.latest_valid_segment_time = segment_time
elif topic.endswith(RecordingsDataTypeEnum.invalid.value):
self.logger.warning(
f"Invalid recording segment detected for {camera} at {segment_time}"
)
self.latest_invalid_segment_time = segment_time
elif topic.endswith(RecordingsDataTypeEnum.latest.value):
if segment_time is not None:
self.latest_cache_segment_time = segment_time
@ -436,17 +413,16 @@ class CameraWatchdog(threading.Thread):
# ensure segments are still being created and that they have valid video data
# Skip checks during grace period to allow segments to start being created
stale_window = timedelta(seconds=self.record_stale_threshold)
cache_stale = not in_grace_period and now_utc > (
latest_cache_dt + stale_window
latest_cache_dt + timedelta(seconds=120)
)
valid_stale = not in_grace_period and now_utc > (
latest_valid_dt + stale_window
latest_valid_dt + timedelta(seconds=120)
)
invalid_stale_condition = (
self.latest_invalid_segment_time > 0
and not in_grace_period
and now_utc > (latest_invalid_dt + stale_window)
and now_utc > (latest_invalid_dt + timedelta(seconds=120))
and self.latest_valid_segment_time
<= self.latest_invalid_segment_time
)
@ -463,7 +439,7 @@ class CameraWatchdog(threading.Thread):
)
self.logger.error(
f"{reason} for {self.config.name} in the last {self.record_stale_threshold}s. Restarting the ffmpeg record process..."
f"{reason} for {self.config.name} in the last 120s. Restarting the ffmpeg record process..."
)
p["process"] = start_or_restart_ffmpeg(
p["cmd"],

View File

@ -28,7 +28,6 @@ class MonitoredProcess:
restart_timestamps: deque[float] = field(
default_factory=lambda: deque(maxlen=MAX_RESTARTS)
)
clean_exit_logged: bool = False
def is_restarting_too_fast(self, now: float) -> bool:
while (
@ -73,9 +72,7 @@ class FrigateWatchdog(threading.Thread):
exitcode = entry.process.exitcode
if exitcode == 0:
if not entry.clean_exit_logged:
logger.info("Process %s exited cleanly, not restarting", entry.name)
entry.clean_exit_logged = True
logger.info("Process %s exited cleanly, not restarting", entry.name)
return
logger.warning(

View File

@ -1,376 +0,0 @@
#!/usr/bin/env python3
"""Analyze keyframe and timestamp structure of Frigate recording segments.
This is a diagnostic tool for investigating seek precision / GOP behavior on
recorded segments. It does not modify anything.
ffprobe is only available inside the Frigate container, at
/usr/lib/ffmpeg/$DEFAULT_FFMPEG_VERSION/bin/ffprobe
This script auto-resolves that path from the DEFAULT_FFMPEG_VERSION env var
(or falls back to scanning /usr/lib/ffmpeg/*/bin/ffprobe). Pass --ffprobe to
override if needed.
All recording segments on the filesystem are in UTC. The --timestamp flag
expects a UTC Unix timestamp.
Typical use:
# Inside the Frigate container (or wherever recordings are mounted)
python3 analyze_recording_keyframes.py <camera_name>
# Analyze 10 most recent segments
python3 analyze_recording_keyframes.py <camera_name> --count 10
# Locate the segment that contains a specific UTC Unix timestamp and
# show it plus surrounding segments
python3 analyze_recording_keyframes.py <camera> --timestamp 1713471234.567
# Custom recordings directory
python3 analyze_recording_keyframes.py <camera> --recordings-dir /media/frigate/recordings
# Override the ffprobe path explicitly
python3 analyze_recording_keyframes.py <camera> --ffprobe /usr/lib/ffmpeg/7.0/bin/ffprobe
"""
import argparse
import datetime
import json
import os
import subprocess
import sys
from pathlib import Path
from statistics import mean, median, stdev
def resolve_ffprobe_path(override: str | None) -> str:
"""Resolve the ffprobe binary path.
Inside the Frigate container, ffprobe lives at
/usr/lib/ffmpeg/{DEFAULT_FFMPEG_VERSION}/bin/ffprobe the exact version
depends on the image build and is exposed as an env var.
"""
if override:
return override
version = os.environ.get("DEFAULT_FFMPEG_VERSION", "")
if version:
path = f"/usr/lib/ffmpeg/{version}/bin/ffprobe"
if Path(path).is_file():
return path
# Fall back to scanning the Frigate ffmpeg install root.
for candidate in sorted(Path("/usr/lib/ffmpeg").glob("*/bin/ffprobe")):
if candidate.is_file():
return str(candidate)
print(
"Could not locate ffprobe. Pass --ffprobe <path> or set "
"DEFAULT_FFMPEG_VERSION.",
file=sys.stderr,
)
sys.exit(1)
def find_recent_segments(recordings_dir: Path, camera: str, count: int) -> list[Path]:
"""Return the N most recent .mp4 segments for the given camera.
Expected layout: <recordings_dir>/<YYYY-MM-DD>/<HH>/<camera>/<MM>.<SS>.mp4
"""
pattern = f"*/*/{camera}/*.mp4"
segments = sorted(recordings_dir.glob(pattern))
return segments[-count:]
def find_segments_near_timestamp(
recordings_dir: Path, camera: str, target_ts: float, count: int
) -> tuple[list[Path], Path | None]:
"""Return `count` segments centered on the one containing `target_ts`.
Also returns the specific segment that should contain the timestamp, so
callers can highlight it in output.
"""
pattern = f"*/*/{camera}/*.mp4"
with_ts: list[tuple[float, Path]] = []
for seg in sorted(recordings_dir.glob(pattern)):
ts = filename_to_timestamp(seg)
if ts is not None:
with_ts.append((ts, seg))
if not with_ts:
return [], None
# Largest filename_ts that is <= target_ts — that's the segment that
# should contain the timestamp (Frigate catalogs segments by filename).
target_idx = -1
for i, (ts, _) in enumerate(with_ts):
if ts <= target_ts:
target_idx = i
else:
break
if target_idx < 0:
# target_ts is before the earliest segment we have — just return the
# first `count` segments so the user can see what's available.
window = with_ts[:count]
return [seg for _, seg in window], None
half = count // 2
start = max(0, target_idx - half)
end = min(len(with_ts), start + count)
start = max(0, end - count)
window = with_ts[start:end]
return [seg for _, seg in window], with_ts[target_idx][1]
def filename_to_timestamp(segment: Path) -> float | None:
"""Parse the wall-clock time from Frigate's segment path layout."""
try:
date = segment.parent.parent.parent.name # YYYY-MM-DD
hour = segment.parent.parent.name # HH
mm_ss = segment.stem # MM.SS
minute, second = mm_ss.split(".")
dt = datetime.datetime.strptime(
f"{date} {hour}:{minute}:{second}",
"%Y-%m-%d %H:%M:%S",
).replace(tzinfo=datetime.timezone.utc)
return dt.timestamp()
except (ValueError, IndexError):
return None
def run_ffprobe(ffprobe: str, args: list[str]) -> dict:
"""Run ffprobe and return parsed JSON, or empty dict on failure."""
result = subprocess.run(
[ffprobe, "-v", "error", *args, "-of", "json"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
print(f" ffprobe error: {result.stderr.strip()}", file=sys.stderr)
return {}
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
return {}
def get_format_info(ffprobe: str, segment: Path) -> tuple[dict, dict]:
"""Return (format_dict, stream_dict) for the first video stream."""
data = run_ffprobe(
ffprobe,
[
"-show_entries",
"format=duration,start_time",
"-show_entries",
"stream=codec_name,profile,r_frame_rate,width,height",
"-select_streams",
"v:0",
str(segment),
],
)
fmt = data.get("format", {})
streams = data.get("streams") or [{}]
return fmt, streams[0]
def get_video_packets(ffprobe: str, segment: Path) -> list[dict]:
"""Return video packets with pts_time and flags."""
data = run_ffprobe(
ffprobe,
[
"-select_streams",
"v",
"-show_entries",
"packet=pts_time,dts_time,flags",
str(segment),
],
)
return data.get("packets", [])
def analyze(ffprobe: str, segment: Path, highlight: bool = False) -> None:
marker = " <-- contains target timestamp" if highlight else ""
print(f"\n=== {segment} ==={marker}")
fmt, stream = get_format_info(ffprobe, segment)
duration = float(fmt.get("duration", 0) or 0)
start_time = float(fmt.get("start_time", 0) or 0)
codec = stream.get("codec_name", "?")
profile = stream.get("profile", "?")
width = stream.get("width", "?")
height = stream.get("height", "?")
fps = stream.get("r_frame_rate", "?/1")
filename_ts = filename_to_timestamp(segment)
filename_iso = (
datetime.datetime.fromtimestamp(
filename_ts, tz=datetime.timezone.utc
).isoformat()
if filename_ts is not None
else "?"
)
print(f" Codec: {codec} ({profile}) {width}x{height} {fps}")
print(f" Filename time: {filename_ts} ({filename_iso})")
print(f" Format duration: {duration:.3f}s")
print(f" Format start: {start_time:.3f}s (PTS offset of first packet)")
packets = get_video_packets(ffprobe, segment)
if not packets:
print(" (no video packets)")
return
keyframe_times: list[float] = []
first_pts: float | None = None
last_pts: float | None = None
for pkt in packets:
pts_str = pkt.get("pts_time")
if pts_str is None or pts_str == "N/A":
continue
pts = float(pts_str)
if first_pts is None:
first_pts = pts
last_pts = pts
if "K" in pkt.get("flags", ""):
keyframe_times.append(pts)
total_packets = len(packets)
kf_count = len(keyframe_times)
print(f" Video packets: {total_packets}")
print(f" Keyframes: {kf_count}")
if first_pts is not None and last_pts is not None:
print(
f" Packet PTS: first={first_pts:.3f}s last={last_pts:.3f}s "
f"span={last_pts - first_pts:.3f}s"
)
if keyframe_times:
print(
f" Keyframe PTS: first={keyframe_times[0]:.3f}s "
f"last={keyframe_times[-1]:.3f}s"
)
formatted = ", ".join(f"{t:.3f}" for t in keyframe_times)
print(f" Keyframe times: [{formatted}]")
if len(keyframe_times) >= 2:
gaps = [b - a for a, b in zip(keyframe_times, keyframe_times[1:])]
avg_fps_estimate = (
total_packets / (last_pts - first_pts)
if last_pts and first_pts is not None and last_pts > first_pts
else 0
)
print(
f" GOP gaps (s): min={min(gaps):.3f} max={max(gaps):.3f} "
f"mean={mean(gaps):.3f} median={median(gaps):.3f}"
)
if len(gaps) > 1:
print(f" stdev={stdev(gaps):.3f}")
print(
f" Est. mean GOP: ~{mean(gaps) * avg_fps_estimate:.1f} frames"
if avg_fps_estimate
else ""
)
if max(gaps) > 5:
print(
" !! Max GOP > 5s — consistent with adaptive/smart codec "
"(even if 'Smart Codec' is off in the UI, some cameras still "
"produce irregular GOPs under specific encoder profiles)"
)
elif kf_count == 1:
print(" !! Only one keyframe in segment — very long GOP")
# Report how well filename time aligns with first-packet PTS.
# (Filename time is what Frigate uses as recording.start_time in the DB.)
if filename_ts is not None and first_pts is not None:
print(
f" Notes: first packet PTS is {first_pts:.3f}s into the file; "
f"Frigate treats filename time as PTS=0 for seek math."
)
def main() -> None:
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("camera", help="Camera name (matches the recordings subfolder)")
parser.add_argument(
"--count",
type=int,
default=5,
help="Number of most recent segments to analyze (default: 5)",
)
parser.add_argument(
"--recordings-dir",
default="/media/frigate/recordings",
help="Path to the recordings directory (default: /media/frigate/recordings)",
)
parser.add_argument(
"--ffprobe",
default=None,
help=(
"Full path to the ffprobe binary. Defaults to the Frigate-bundled "
"binary at /usr/lib/ffmpeg/$DEFAULT_FFMPEG_VERSION/bin/ffprobe."
),
)
parser.add_argument(
"--timestamp",
type=float,
default=None,
help=(
"Unix timestamp (UTC seconds, decimals allowed) to locate. The "
"script finds the segment that should contain this time and "
"analyzes it plus surrounding segments (count controls the "
"window). All on-disk segments are stored in UTC, so pass a UTC "
"Unix timestamp."
),
)
args = parser.parse_args()
ffprobe = resolve_ffprobe_path(args.ffprobe)
recordings_dir = Path(args.recordings_dir)
if not recordings_dir.is_dir():
print(
f"Recordings directory not found: {recordings_dir}",
file=sys.stderr,
)
sys.exit(1)
target_segment: Path | None = None
if args.timestamp is not None:
segments, target_segment = find_segments_near_timestamp(
recordings_dir, args.camera, args.timestamp, args.count
)
target_iso = datetime.datetime.fromtimestamp(
args.timestamp, tz=datetime.timezone.utc
).isoformat()
mode = f"around timestamp {args.timestamp} ({target_iso})"
else:
segments = find_recent_segments(recordings_dir, args.camera, args.count)
mode = "most recent"
if not segments:
print(
f"No segments found for camera '{args.camera}' under {recordings_dir}",
file=sys.stderr,
)
sys.exit(1)
if args.timestamp is not None and target_segment is None:
print(
f"!! Target timestamp {args.timestamp} is before the earliest "
f"segment on disk; showing the earliest available segments instead.",
file=sys.stderr,
)
print(
f"Analyzing {len(segments)} {mode} segment(s) for camera "
f"'{args.camera}' under {recordings_dir} (ffprobe: {ffprobe})"
)
for segment in segments:
analyze(ffprobe, segment, highlight=(segment == target_segment))
if __name__ == "__main__":
main()

View File

@ -1,783 +0,0 @@
"""
Face recognition investigation script.
Standalone replica of Frigate's ArcFace pipeline (see
frigate/data_processing/common/face/model.py and
frigate/embeddings/onnx/face_embedding.py) for analyzing a face collection
outside the running service. Useful for:
- Diagnosing why a person's collection produces false positives
- Finding outlier/contaminating training images
- Inspecting the effect of the shipped vector-wise outlier filter
Layout:
- Core pipeline: LandmarkAligner, ArcFaceEmbedder, arcface_preprocess,
similarity_to_confidence, blur_reduction all mirroring the production
code exactly
- Default run: summarize positive and negative sets against a baseline
trim_mean class representation
- Optional diagnostics (flags): vector-outlier filter behavior, degenerate
"tiny crop" embedding clustering, and multi-identity contamination
Usage:
python3 face_investigate.py \\
--positive <positive_folder> \\
--negative <negative_folder> \\
[--model-cache /path/to/model_cache] \\
[--vector-outlier] [--degenerate] [--contamination]
The positive folder should contain training images for a single identity
(same layout as FACE_DIR/<name>/*.webp). The negative folder should contain
runtime crops to test against a mix of true matches and misfires.
"""
from __future__ import annotations
import argparse
import os
import sys
from dataclasses import dataclass
from typing import Iterable
import cv2
import numpy as np
import onnxruntime as ort
from PIL import Image
from scipy import stats
ARCFACE_INPUT_SIZE = 112
# ---------------------------------------------------------------------------
# Replicated Frigate pipeline
# ---------------------------------------------------------------------------
def _process_image_frigate(image: np.ndarray) -> Image.Image:
"""Mirror BaseEmbedding._process_image for an ndarray input.
NOTE: Frigate passes the output of `cv2.imread` (BGR) directly in. PIL's
`Image.fromarray` does NOT reorder channels, so the embedder effectively
receives a BGR-ordered tensor. We replicate that faithfully here. (Tested
swapping to RGB produces near-identical embeddings; this model is
robust to channel order.)
"""
return Image.fromarray(image)
def arcface_preprocess(image_bgr: np.ndarray) -> np.ndarray:
"""Mirror ArcfaceEmbedding._preprocess_inputs."""
pil = _process_image_frigate(image_bgr)
width, height = pil.size
if width != ARCFACE_INPUT_SIZE or height != ARCFACE_INPUT_SIZE:
if width > height:
new_height = int(((height / width) * ARCFACE_INPUT_SIZE) // 4 * 4)
pil = pil.resize((ARCFACE_INPUT_SIZE, new_height))
else:
new_width = int(((width / height) * ARCFACE_INPUT_SIZE) // 4 * 4)
pil = pil.resize((new_width, ARCFACE_INPUT_SIZE))
og = np.array(pil).astype(np.float32)
og_h, og_w, channels = og.shape
frame = np.zeros(
(ARCFACE_INPUT_SIZE, ARCFACE_INPUT_SIZE, channels), dtype=np.float32
)
x_center = (ARCFACE_INPUT_SIZE - og_w) // 2
y_center = (ARCFACE_INPUT_SIZE - og_h) // 2
frame[y_center : y_center + og_h, x_center : x_center + og_w] = og
frame = (frame / 127.5) - 1.0
frame = np.transpose(frame, (2, 0, 1))
frame = np.expand_dims(frame, axis=0)
return frame
class LandmarkAligner:
"""Mirror FaceRecognizer.align_face."""
def __init__(self, landmark_model_path: str):
if not os.path.exists(landmark_model_path):
raise FileNotFoundError(landmark_model_path)
self.detector = cv2.face.createFacemarkLBF()
self.detector.loadModel(landmark_model_path)
def align(
self, image: np.ndarray, out_w: int, out_h: int
) -> tuple[np.ndarray, dict]:
land_image = (
cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if image.ndim == 3 else image
)
_, lands = self.detector.fit(
land_image, np.array([(0, 0, land_image.shape[1], land_image.shape[0])])
)
landmarks = lands[0][0]
leftEyePts = landmarks[42:48]
rightEyePts = landmarks[36:42]
leftEyeCenter = leftEyePts.mean(axis=0).astype("int")
rightEyeCenter = rightEyePts.mean(axis=0).astype("int")
dY = rightEyeCenter[1] - leftEyeCenter[1]
dX = rightEyeCenter[0] - leftEyeCenter[0]
angle = np.degrees(np.arctan2(dY, dX)) - 180
dist = float(np.sqrt((dX**2) + (dY**2)))
desiredRightEyeX = 1.0 - 0.35
desiredDist = (desiredRightEyeX - 0.35) * out_w
scale = desiredDist / dist if dist > 0 else 1.0
eyesCenter = (
int((leftEyeCenter[0] + rightEyeCenter[0]) // 2),
int((leftEyeCenter[1] + rightEyeCenter[1]) // 2),
)
M = cv2.getRotationMatrix2D(eyesCenter, angle, scale)
tX = out_w * 0.5
tY = out_h * 0.35
M[0, 2] += tX - eyesCenter[0]
M[1, 2] += tY - eyesCenter[1]
aligned = cv2.warpAffine(
image, M, (out_w, out_h), flags=cv2.INTER_CUBIC
)
info = dict(
angle=float(angle),
eye_dist_px=dist,
scale=float(scale),
landmarks=landmarks,
)
return aligned, info
class ArcFaceEmbedder:
def __init__(self, model_path: str):
self.session = ort.InferenceSession(
model_path, providers=["CPUExecutionProvider"]
)
self.input_name = self.session.get_inputs()[0].name
def embed(self, image_bgr: np.ndarray) -> np.ndarray:
tensor = arcface_preprocess(image_bgr)
out = self.session.run(None, {self.input_name: tensor})[0]
return out.squeeze()
def similarity_to_confidence(
cos_sim: float,
median: float = 0.3,
range_width: float = 0.6,
slope_factor: float = 12,
) -> float:
slope = slope_factor / range_width
return float(1.0 / (1.0 + np.exp(-slope * (cos_sim - median))))
def laplacian_variance(image: np.ndarray) -> float:
return float(cv2.Laplacian(image, cv2.CV_64F).var())
def blur_reduction(variance: float) -> float:
if variance < 120:
return 0.06
elif variance < 160:
return 0.04
elif variance < 200:
return 0.02
elif variance < 250:
return 0.01
return 0.0
def cosine(a: np.ndarray, b: np.ndarray) -> float:
denom = np.linalg.norm(a) * np.linalg.norm(b)
if denom == 0:
return 0.0
return float(np.dot(a, b) / denom)
def l2(v: np.ndarray) -> np.ndarray:
return v / (np.linalg.norm(v) + 1e-9)
# ---------------------------------------------------------------------------
# Sample loading
# ---------------------------------------------------------------------------
@dataclass
class FaceSample:
path: str
shape: tuple[int, int]
embedding: np.ndarray
blur_var: float
align_info: dict
def load_folder(
folder: str, aligner: LandmarkAligner, embedder: ArcFaceEmbedder
) -> list[FaceSample]:
samples: list[FaceSample] = []
names = sorted(os.listdir(folder))
for name in names:
if name.startswith("."):
continue
path = os.path.join(folder, name)
if not os.path.isfile(path):
continue
img = cv2.imread(path)
if img is None:
print(f" [skip unreadable] {name}")
continue
aligned, info = aligner.align(img, img.shape[1], img.shape[0])
emb = embedder.embed(aligned)
samples.append(
FaceSample(
path=path,
shape=(img.shape[1], img.shape[0]),
embedding=emb,
blur_var=laplacian_variance(img),
align_info=info,
)
)
return samples
def trimmed_mean(embs: Iterable[np.ndarray], trim: float = 0.15) -> np.ndarray:
arr = np.stack(list(embs), axis=0)
return stats.trim_mean(arr, trim, axis=0)
# ---------------------------------------------------------------------------
# Baseline analyses (always run)
# ---------------------------------------------------------------------------
def summarize_positive(samples: list[FaceSample], mean_emb: np.ndarray) -> None:
"""Summary of training set: per-sample cos to class mean, intra-class stats.
Outliers with cos far below the rest are likely degrading the mean
they'd be the first candidates the shipped vector-outlier filter drops.
"""
print("\n" + "=" * 78)
print(f"POSITIVE SET ANALYSIS ({len(samples)} images)")
print("=" * 78)
rows = []
for s in samples:
cs = cosine(s.embedding, mean_emb)
conf = similarity_to_confidence(cs)
red = blur_reduction(s.blur_var)
rows.append(
dict(
name=os.path.basename(s.path),
shape=f"{s.shape[0]}x{s.shape[1]}",
eye_px=s.align_info["eye_dist_px"],
angle=s.align_info["angle"] + 180,
blur=s.blur_var,
cos=cs,
conf=conf,
red=red,
adj_conf=max(0.0, conf - red),
)
)
rows.sort(key=lambda r: r["cos"])
sims = np.array([r["cos"] for r in rows])
print(
f"\nCosine-to-trimmed-mean: mean={sims.mean():.3f} std={sims.std():.3f} "
f"min={sims.min():.3f} max={sims.max():.3f}"
)
print("\n-- Worst matches (bottom 10, most likely hurting the mean) --")
print(
f"{'cos':>6} {'conf':>6} {'blur':>7} {'eyes':>6} "
f"{'angle':>6} {'shape':>9} name"
)
for r in rows[:10]:
print(
f"{r['cos']:6.3f} {r['conf']:6.3f} {r['blur']:7.1f} "
f"{r['eye_px']:6.1f} {r['angle']:6.1f} {r['shape']:>9} {r['name']}"
)
print("\n-- Best matches (top 5) --")
for r in rows[-5:][::-1]:
print(
f"{r['cos']:6.3f} {r['conf']:6.3f} {r['blur']:7.1f} "
f"{r['eye_px']:6.1f} {r['angle']:6.1f} {r['shape']:>9} {r['name']}"
)
# Pairwise analysis — flags embeddings poorly correlated with the rest
print("\n-- Pairwise intra-class similarity (mean cos vs. other positives) --")
embs = np.stack([s.embedding for s in samples], axis=0)
norms = embs / (np.linalg.norm(embs, axis=1, keepdims=True) + 1e-9)
sim_matrix = norms @ norms.T
np.fill_diagonal(sim_matrix, np.nan)
mean_pairwise = np.nanmean(sim_matrix, axis=1)
names = [os.path.basename(s.path) for s in samples]
ordered = sorted(zip(names, mean_pairwise), key=lambda t: t[1])
print(f"{'mean_cos':>9} name")
for nm, mp in ordered[:10]:
print(f"{mp:9.3f} {nm}")
print(f"\n overall mean pairwise cos: {np.nanmean(sim_matrix):.3f}")
print(f" median pairwise cos: {np.nanmedian(sim_matrix):.3f}")
def summarize_negative(
neg_samples: list[FaceSample],
mean_emb: np.ndarray,
pos_samples: list[FaceSample],
) -> None:
"""Score each negative against the class mean, then show its top-3
nearest positives. High-scoring negatives that match specific outlier
positives hint at training-set contamination.
"""
print("\n" + "=" * 78)
print(f"NEGATIVE SET ANALYSIS ({len(neg_samples)} images)")
print("=" * 78)
print(
f"\n{'cos':>6} {'conf':>6} {'red':>5} {'adj':>5} "
f"{'blur':>7} {'eyes':>6} {'shape':>9} name"
)
for s in neg_samples:
cs = cosine(s.embedding, mean_emb)
conf = similarity_to_confidence(cs)
red = blur_reduction(s.blur_var)
print(
f"{cs:6.3f} {conf:6.3f} {red:5.2f} {max(0, conf - red):5.2f} "
f"{s.blur_var:7.1f} {s.align_info['eye_dist_px']:6.1f} "
f"{s.shape[0]}x{s.shape[1]:<5} {os.path.basename(s.path)}"
)
print("\n-- For each negative, top-3 most similar positives --")
pos_embs = np.stack([p.embedding for p in pos_samples])
pos_norm = pos_embs / (np.linalg.norm(pos_embs, axis=1, keepdims=True) + 1e-9)
for s in neg_samples:
v = s.embedding / (np.linalg.norm(s.embedding) + 1e-9)
sims = pos_norm @ v
idx = np.argsort(-sims)[:3]
print(f"\n {os.path.basename(s.path)}:")
for i in idx:
print(
f" {sims[i]:6.3f} {os.path.basename(pos_samples[i].path)} "
f"blur={pos_samples[i].blur_var:.1f} "
f"eyes={pos_samples[i].align_info['eye_dist_px']:.1f}"
)
# ---------------------------------------------------------------------------
# Optional diagnostics
# ---------------------------------------------------------------------------
def vector_outlier_test(
pos: list[FaceSample], neg: list[FaceSample], base_trim: float = 0.15
) -> None:
"""Measure the shipped vector-wise outlier filter at various thresholds.
The production filter at `build_class_mean` in
frigate/data_processing/common/face/model.py uses T=0.30. This test
sweeps T so you can see which images would be dropped on a new collection
and how that affects the negative scores.
Algorithm: iteratively recompute trim_mean on the kept set, drop any
embedding with cos < T to that mean, repeat until converged. Floor at
50% of the collection to avoid collapse.
"""
print("\n" + "=" * 78)
print("VECTOR-WISE OUTLIER PRE-FILTER — layered on trim_mean(0.15)")
print("=" * 78)
all_embs = np.stack([s.embedding for s in pos])
def iterative_mean(
embs: np.ndarray,
threshold: float,
iters: int = 3,
min_keep_frac: float = 0.5,
) -> tuple[np.ndarray, np.ndarray]:
keep = np.ones(len(embs), dtype=bool)
floor = max(5, int(np.ceil(min_keep_frac * len(embs))))
for _ in range(iters):
m = stats.trim_mean(embs[keep], base_trim, axis=0)
m_norm = m / (np.linalg.norm(m) + 1e-9)
e_norms = embs / (np.linalg.norm(embs, axis=1, keepdims=True) + 1e-9)
cos_to_mean = e_norms @ m_norm
new_keep = cos_to_mean >= threshold
if new_keep.sum() < floor:
top_idx = np.argsort(-cos_to_mean)[:floor]
new_keep = np.zeros_like(new_keep)
new_keep[top_idx] = True
if np.array_equal(new_keep, keep):
break
keep = new_keep
final = stats.trim_mean(embs[keep], base_trim, axis=0)
return final, keep
provisional = stats.trim_mean(all_embs, base_trim, axis=0)
p_norm = provisional / (np.linalg.norm(provisional) + 1e-9)
e_norms_all = all_embs / (np.linalg.norm(all_embs, axis=1, keepdims=True) + 1e-9)
cos_to_prov = e_norms_all @ p_norm
print("\nDistribution of cos(positive, provisional trim_mean):")
print(
f" min={cos_to_prov.min():.3f} p10={np.percentile(cos_to_prov, 10):.3f} "
f"p25={np.percentile(cos_to_prov, 25):.3f} "
f"median={np.median(cos_to_prov):.3f} "
f"p75={np.percentile(cos_to_prov, 75):.3f} max={cos_to_prov.max():.3f}"
)
baseline_mean = stats.trim_mean(all_embs, base_trim, axis=0)
baseline_pos = np.array([cosine(p.embedding, baseline_mean) for p in pos])
baseline_neg = (
np.array([cosine(n.embedding, baseline_mean) for n in neg])
if neg
else np.array([])
)
baseline_conf_neg = np.array(
[similarity_to_confidence(c) for c in baseline_neg]
)
print(
f"\nBaseline (trim_mean only, {len(pos)} images):"
f"\n pos cos min={baseline_pos.min():.3f} "
f"mean={baseline_pos.mean():.3f} max={baseline_pos.max():.3f}"
)
if len(neg):
print(
f" neg cos min={baseline_neg.min():.3f} "
f"mean={baseline_neg.mean():.3f} max={baseline_neg.max():.3f}"
)
print(
f" neg conf min={baseline_conf_neg.min():.3f} "
f"mean={baseline_conf_neg.mean():.3f} max={baseline_conf_neg.max():.3f}"
)
print(
f" margin (pos.min - neg.max): "
f"{baseline_pos.min() - baseline_neg.max():+.3f}"
)
print("\nIterative (refine mean → drop vectors with cos<T → repeat):")
print(
f"\n{'T':>5} {'kept':>6} {'pos min':>7} {'pos mean':>8} "
f"{'neg max':>7} {'neg mean':>8} {'neg conf.max':>12} {'margin':>7}"
)
for T in [0.15, 0.20, 0.25, 0.28, 0.30, 0.33, 0.36, 0.40]:
mean, keep = iterative_mean(all_embs, T)
pos_sims = np.array([cosine(p.embedding, mean) for p in pos])
neg_sims = (
np.array([cosine(n.embedding, mean) for n in neg])
if neg
else np.array([])
)
neg_conf = np.array([similarity_to_confidence(c) for c in neg_sims])
margin = pos_sims.min() - (neg_sims.max() if len(neg_sims) else 0)
print(
f"{T:5.2f} {int(keep.sum()):>3}/{len(pos):<2} "
f"{pos_sims.min():7.3f} {pos_sims.mean():8.3f} "
f"{neg_sims.max() if len(neg_sims) else float('nan'):7.3f} "
f"{neg_sims.mean() if len(neg_sims) else float('nan'):8.3f} "
f"{neg_conf.max() if len(neg_conf) else float('nan'):12.3f} "
f"{margin:+7.3f}"
)
# Show which images get dropped at the shipped threshold + neighbors
for T_show in (0.25, 0.30, 0.33):
_, keep = iterative_mean(all_embs, T_show)
print(
f"\nAt T={T_show}, the {int((~keep).sum())} dropped positives are:"
)
final_mean = stats.trim_mean(all_embs[keep], base_trim, axis=0)
m_n = final_mean / (np.linalg.norm(final_mean) + 1e-9)
for i, (p, k) in enumerate(zip(pos, keep)):
if not k:
e_n = p.embedding / (np.linalg.norm(p.embedding) + 1e-9)
cos_final = float(e_n @ m_n)
print(
f" cos_to_clean_mean={cos_final:6.3f} "
f"shape={p.shape[0]}x{p.shape[1]} "
f"eyes={p.align_info['eye_dist_px']:6.1f} "
f"blur={p.blur_var:7.1f} "
f"{os.path.basename(p.path)}"
)
def degenerate_embedding_test(
pos: list[FaceSample], neg: list[FaceSample]
) -> None:
"""Detect whether negatives and low-quality positives share a degenerate
'tiny/noisy face' region of the embedding space.
Signal: if neg-to-neg cos is higher than pos-to-pos cos, the negatives
aren't really per-identity embeddings — they're dominated by upsample /
low-resolution artifacts that all map to a similar corner of embedding
space regardless of who the face belongs to.
Also rebuilds the mean using only high-intra-similarity positives to
show whether a cleaner training set separates the negatives.
"""
print("\n" + "=" * 78)
print("DEGENERATE-EMBEDDING TEST")
print("=" * 78)
pos_embs = np.stack([l2(s.embedding) for s in pos])
neg_embs = np.stack([l2(s.embedding) for s in neg])
nn = neg_embs @ neg_embs.T
np.fill_diagonal(nn, np.nan)
pp = pos_embs @ pos_embs.T
np.fill_diagonal(pp, np.nan)
pn = pos_embs @ neg_embs.T
print(
f"\n neg<->neg mean cos : {np.nanmean(nn):.3f} "
f"(how tightly negatives cluster together)"
)
print(
f" pos<->pos mean cos : {np.nanmean(pp):.3f} "
f"(how tightly positives cluster)"
)
print(
f" pos<->neg mean cos : {pn.mean():.3f} "
f"(cross-class — should be low for a clean class)"
)
if np.nanmean(nn) > np.nanmean(pp):
print(
"\n >> neg<->neg > pos<->pos: negatives cluster more tightly than\n"
" positives. This is the degenerate-embedding signature —\n"
" upsampled tiny crops share a common 'face-like blob' region\n"
" regardless of identity."
)
mean_intra = np.nanmean(pp, axis=1)
for thresh in (0.30, 0.33, 0.36):
keep = mean_intra >= thresh
if keep.sum() < 5:
continue
clean_embs = [pos[i].embedding for i in range(len(pos)) if keep[i]]
clean_mean = stats.trim_mean(np.stack(clean_embs), 0.15, axis=0)
neg_scores = np.array([cosine(n.embedding, clean_mean) for n in neg])
neg_confs = np.array([similarity_to_confidence(c) for c in neg_scores])
pos_scores = np.array(
[
cosine(pos[i].embedding, clean_mean)
for i in range(len(pos))
if keep[i]
]
)
print(
f"\n mean_intra >= {thresh}: keeping {int(keep.sum())}/{len(pos)} positives"
)
print(
f" pos cos vs mean : min={pos_scores.min():.3f} "
f"mean={pos_scores.mean():.3f} max={pos_scores.max():.3f}"
)
print(
f" neg cos vs mean : min={neg_scores.min():.3f} "
f"mean={neg_scores.mean():.3f} max={neg_scores.max():.3f}"
)
print(
f" neg conf : min={neg_confs.min():.3f} "
f"mean={neg_confs.mean():.3f} max={neg_confs.max():.3f}"
)
print(
f" margin (pos.min - neg.max): "
f"{pos_scores.min() - neg_scores.max():+.3f}"
)
def contamination_analysis(
pos: list[FaceSample], neg: list[FaceSample]
) -> None:
"""Check whether the positive collection contains a second identity.
Two signals:
(a) Per-positive: if an image is closer to at least one negative than
to the rest of the positive class, it's likely a mislabeled face.
(b) 2-means split of the positive embeddings: if one cluster center
lands close to the negative mean, that cluster is a contaminating
sub-identity that's pulling the class mean toward the negatives.
"""
print("\n" + "=" * 78)
print("CONTAMINATION ANALYSIS")
print("=" * 78)
pos_embs = np.stack([l2(s.embedding) for s in pos])
neg_embs = np.stack([l2(s.embedding) for s in neg])
pos_names = [os.path.basename(s.path) for s in pos]
pos_pos = pos_embs @ pos_embs.T
np.fill_diagonal(pos_pos, np.nan)
pos_neg = pos_embs @ neg_embs.T
mean_intra = np.nanmean(pos_pos, axis=1)
max_to_neg = pos_neg.max(axis=1)
mean_to_neg = pos_neg.mean(axis=1)
print(
"\nPositives closer to a negative than to their own class avg"
"\n(these are candidates for mislabeled images):"
)
print(
f"\n{'max_neg':>7} {'mean_neg':>8} {'mean_intra':>10} "
f"{'delta':>6} name"
)
rows = list(zip(pos_names, max_to_neg, mean_to_neg, mean_intra))
rows.sort(key=lambda r: -(r[1] - r[3]))
for nm, mxn, mnn, mi in rows[:15]:
delta = mxn - mi
marker = " <<" if delta > 0 else ""
print(f"{mxn:7.3f} {mnn:8.3f} {mi:10.3f} {delta:6.3f} {nm}{marker}")
# 2-means in cosine space (no sklearn dependency).
print("\n2-means split of positive embeddings (cosine space):")
rng = np.random.default_rng(0)
best = None
for _ in range(5):
idx = rng.choice(len(pos_embs), 2, replace=False)
centers = pos_embs[idx].copy()
for _ in range(50):
sims = pos_embs @ centers.T
labels = np.argmax(sims, axis=1)
new_centers = np.stack(
[
l2(pos_embs[labels == k].mean(axis=0))
if np.any(labels == k)
else centers[k]
for k in range(2)
]
)
if np.allclose(new_centers, centers):
break
centers = new_centers
tight = float(np.mean([sims[i, labels[i]] for i in range(len(labels))]))
if best is None or tight > best[0]:
best = (tight, labels.copy(), centers.copy())
_, labels, centers = best
sizes = [int((labels == k).sum()) for k in range(2)]
neg_mean = l2(neg_embs.mean(axis=0))
print(
f" cluster 0: size={sizes[0]:>2} "
f"center<->other_center_cos={float(centers[0] @ centers[1]):.3f} "
f"center<->neg_mean_cos={float(centers[0] @ neg_mean):.3f}"
)
print(
f" cluster 1: size={sizes[1]:>2} "
f"center<->neg_mean_cos={float(centers[1] @ neg_mean):.3f}"
)
neg_aligned = 0 if centers[0] @ neg_mean > centers[1] @ neg_mean else 1
print(
f"\n cluster {neg_aligned} is more similar to the negatives — "
f"its members are the contamination candidates:"
)
for i, lbl in enumerate(labels):
if lbl == neg_aligned:
print(
f" max_to_neg={max_to_neg[i]:.3f} "
f"mean_intra={mean_intra[i]:.3f} {pos_names[i]}"
)
keep_mask = labels != neg_aligned
if keep_mask.sum() >= 3:
clean_embs = [pos[i].embedding for i in range(len(pos)) if keep_mask[i]]
clean_mean = stats.trim_mean(np.stack(clean_embs), 0.15, axis=0)
print(
f"\n Rebuilding class mean from the OTHER cluster "
f"({keep_mask.sum()} images):"
)
print(f" {'cos':>6} {'conf':>6} name")
for n in neg:
cs = cosine(n.embedding, clean_mean)
cf = similarity_to_confidence(cs)
print(f" {cs:6.3f} {cf:6.3f} {os.path.basename(n.path)}")
# ---------------------------------------------------------------------------
# main
# ---------------------------------------------------------------------------
def main() -> int:
ap = argparse.ArgumentParser(
description="Analyze a face recognition collection outside Frigate.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
ap.add_argument("--positive", required=True, help="Training folder for one identity")
ap.add_argument(
"--negative",
default=None,
help="Runtime-crop folder to score against (optional)",
)
ap.add_argument(
"--model-cache",
default="/config/model_cache",
help="Directory containing facedet/arcface.onnx and facedet/landmarkdet.yaml",
)
ap.add_argument(
"--trim",
type=float,
default=0.15,
help="trim_mean proportion (Frigate uses 0.15)",
)
ap.add_argument(
"--vector-outlier",
action="store_true",
help="Sweep the vector-wise outlier filter threshold",
)
ap.add_argument(
"--degenerate",
action="store_true",
help="Test whether negatives share a degenerate embedding region",
)
ap.add_argument(
"--contamination",
action="store_true",
help="Check whether the positive folder contains a second identity",
)
args = ap.parse_args()
arcface_path = os.path.join(args.model_cache, "facedet", "arcface.onnx")
landmark_path = os.path.join(args.model_cache, "facedet", "landmarkdet.yaml")
for p in (arcface_path, landmark_path):
if not os.path.exists(p):
print(f"ERROR: model file not found: {p}")
return 1
print(f"Loading ArcFace from {arcface_path}")
embedder = ArcFaceEmbedder(arcface_path)
print(f"Loading landmark model from {landmark_path}")
aligner = LandmarkAligner(landmark_path)
print(f"\nLoading positives from {args.positive} ...")
pos = load_folder(args.positive, aligner, embedder)
print(f" {len(pos)} positives loaded")
neg: list[FaceSample] = []
if args.negative:
print(f"\nLoading negatives from {args.negative} ...")
neg = load_folder(args.negative, aligner, embedder)
print(f" {len(neg)} negatives loaded")
if not pos:
print("no positive samples — aborting")
return 1
mean_emb = trimmed_mean([s.embedding for s in pos], trim=args.trim)
summarize_positive(pos, mean_emb)
if neg:
summarize_negative(neg, mean_emb, pos)
if args.vector_outlier:
vector_outlier_test(pos, neg, args.trim)
if args.degenerate and neg:
degenerate_embedding_test(pos, neg)
if args.contamination and neg:
contamination_analysis(pos, neg)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -1,114 +1,4 @@
import { test, expect } from "../fixtures/frigate-test";
import {
expectBodyInteractive,
waitForBodyInteractive,
} from "../helpers/overlay-interaction";
test.describe("Export Page - Delete race @high", () => {
// Empirical guard for radix-ui/primitives#3445: when a modal DropdownMenu
// opens an AlertDialog and the AlertDialog's confirm action causes the
// parent's optimistic cache update to unmount the card, we want to know
// whether the deduped react-dismissable-layer (1.1.11) handles the
// pointer-events stack cleanup or whether `modal={false}` is still
// required on the DropdownMenu. The classic "canonical" pattern, distinct
// from the FaceSelectionDialog auto-unmount race already covered by
// face-library.spec.ts.
test("deleting an export via dropdown→alert→confirm leaves body interactive", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
const initialExports = [
{
id: "export-race-001",
camera: "front_door",
name: "Race - Test Export",
date: 1775490731.3863528,
video_path: "/exports/export-race-001.mp4",
thumb_path: "/exports/export-race-001-thumb.jpg",
in_progress: false,
export_case_id: null,
},
];
let deleted = false;
await frigateApp.installDefaults({
exports: initialExports,
});
// Flip /api/export to empty after the delete POST is observed so the
// page's SWR mutate sees the export gone.
await frigateApp.page.route("**/api/export**", async (route) => {
const payload = deleted ? [] : initialExports;
await route.fulfill({ json: payload });
});
await frigateApp.page.route("**/api/exports/delete", async (route) => {
deleted = true;
const delayMs = Number(
(globalThis as { process?: { env?: Record<string, string> } }).process
?.env?.DELETE_DELAY_MS ?? "100",
);
if (delayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
await route.fulfill({ json: { success: true } });
});
await frigateApp.goto("/export");
await expect(frigateApp.page.getByText("Race - Test Export")).toBeVisible({
timeout: 5_000,
});
// Open the kebab menu on the export card. The kebab uses the
// (misleading) aria-label "Edit name" from ExportCard's source — it
// wraps the FiMoreVertical icon. There is exactly one such button on
// the page once we have a single export rendered.
const kebab = frigateApp.page
.getByRole("button", { name: /edit name/i })
.first();
await expect(kebab).toBeVisible({ timeout: 5_000 });
await kebab.click();
const menu = frigateApp.page
.locator('[role="menu"], [data-radix-menu-content]')
.first();
await expect(menu).toBeVisible({ timeout: 3_000 });
// Delete Export
await menu
.getByRole("menuitem", { name: /delete export/i })
.first()
.click();
// AlertDialog at page level. The confirm button's accessible name is
// "Delete Export" (its aria-label), the visible text is just "Delete".
const confirm = frigateApp.page.getByRole("alertdialog");
await expect(confirm).toBeVisible({ timeout: 3_000 });
await confirm
.getByRole("button", { name: /^delete export$/i })
.first()
.click();
// The card optimistically disappears, the dialog closes, and body
// pointer-events must come unstuck.
await expect(
frigateApp.page.getByText("Race - Test Export"),
).not.toBeVisible({ timeout: 5_000 });
await waitForBodyInteractive(frigateApp.page, 5_000);
await expectBodyInteractive(frigateApp.page);
// Sanity: another page-level button still responds.
const newCase = frigateApp.page.getByRole("button", { name: /new case/i });
await expect(newCase).toBeVisible({ timeout: 3_000 });
await newCase.click();
await expect(
frigateApp.page.getByRole("dialog").filter({ hasText: /create case/i }),
).toBeVisible({ timeout: 3_000 });
});
});
test.describe("Export Page - Overview @high", () => {
test("renders uncategorized exports and case cards from mock data", async ({

View File

@ -358,158 +358,6 @@ test.describe("FaceSelectionDialog @high", () => {
await frigateApp.page.keyboard.press("Escape");
await expect(menu).not.toBeVisible({ timeout: 3_000 });
});
test("classifying the last image in a group leaves body interactive", async ({
frigateApp,
}) => {
// Regression guard for the stuck body pointer-events bug when the
// last image in a grouped-recognition detail Dialog is classified.
// Tracked upstream at radix-ui/primitives#3445.
//
// Root cause: when the user clicks a FaceSelectionDialog menu item,
// the modal DropdownMenu enters its exit animation (Radix's Presence
// keeps it in the DOM with data-state="closed" until animationend).
// While that is in flight the classify axios resolves, SWR removes
// the image from /api/faces, the parent's map no longer renders the
// grouped card, and React unmounts the subtree — including the still-
// animating DropdownMenu's Presence container. DismissableLayer's
// shared modal-layer stack can't reconcile the interrupted exit, so
// the `body { pointer-events: none }` entry it put on mount is never
// popped and the rest of the UI becomes unclickable.
//
// The fix is `modal={false}` on the FaceSelectionDialog's
// DropdownMenu (desktop path only). With modal=false the DropdownMenu
// never puts an entry on DismissableLayer's body-pointer-events stack
// in the first place, so there's nothing to leak when its Presence is
// torn down mid-animation. The Radix-community-documented workaround
// for #3445.
//
// The bug only reproduces when the mock resolves fast enough that
// the parent unmounts before the dropdown's exit animation finishes.
// Measured window via a 3x sweep on the pre-fix build: 0200 ms
// triggers it; 300 ms+ no longer reproduces. Production LAN networks
// sit comfortably inside the bad window, while `npm run dev` seems
// to mask it via React StrictMode's double-effect scheduling.
const EVENT_ID = "1775487131.3863528-race";
const initialFaces = withGroupedTrainingAttempt(basicFacesMock(), {
eventId: EVENT_ID,
attempts: [
{ timestamp: 1775487131.3863528, label: "unknown", score: 0.95 },
],
});
let classified = false;
await frigateApp.installDefaults({
faces: initialFaces,
events: [
{
id: EVENT_ID,
label: "person",
sub_label: null,
camera: "front_door",
start_time: 1775487131.3863528,
end_time: 1775487161.3863528,
false_positive: false,
zones: ["front_yard"],
thumbnail: null,
has_clip: true,
has_snapshot: true,
retain_indefinitely: false,
plus_id: null,
model_hash: "abc123",
detector_type: "cpu",
model_type: "ssd",
data: {
top_score: 0.92,
score: 0.92,
region: [0.1, 0.1, 0.5, 0.8],
box: [0.2, 0.15, 0.45, 0.75],
area: 0.18,
ratio: 0.6,
type: "object",
path_data: [],
},
},
],
});
// Re-route /api/faces to flip to the "train empty" payload once the
// classify POST has been received. Registered AFTER installDefaults so
// Playwright's LIFO route matching hits this handler first.
await frigateApp.page.route("**/api/faces", async (route) => {
const payload = classified ? basicFacesMock() : initialFaces;
await route.fulfill({ json: payload });
});
// Hold the classify POST briefly. The race opens when the parent
// unmounts before the dropdown's exit animation finishes (~200ms
// in Radix). 100ms keeps us comfortably inside that window and
// reliably triggered the bug in a 3x sweep across 0/50/100/200ms
// on the pre-fix build. CLASSIFY_DELAY_MS overrides for local sweeps.
const delayMs = Number(
(globalThis as { process?: { env?: Record<string, string> } }).process
?.env?.CLASSIFY_DELAY_MS ?? "100",
);
await frigateApp.page.route(
"**/api/faces/train/*/classify",
async (route) => {
classified = true;
if (delayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
await route.fulfill({ json: { success: true } });
},
);
await frigateApp.goto("/faces");
// Open the grouped detail Dialog.
const groupedImage = frigateApp.page
.locator('img[src*="clips/faces/train/"]')
.first();
await expect(groupedImage).toBeVisible({ timeout: 5_000 });
await groupedImage.locator("xpath=..").click();
const dialog = frigateApp.page
.getByRole("dialog")
.filter({
has: frigateApp.page.locator('img[src*="clips/faces/train/"]'),
})
.first();
await expect(dialog).toBeVisible({ timeout: 5_000 });
// Single attempt → single `+` trigger.
const triggers = dialog.locator('[aria-haspopup="menu"]');
await expect(triggers).toHaveCount(1);
await triggers.first().click();
const menu = frigateApp.page
.locator('[role="menu"], [data-radix-menu-content]')
.first();
await expect(menu).toBeVisible({ timeout: 5_000 });
await menu.getByRole("menuitem", { name: /^alice$/i }).click();
// The Dialog must leave the tree cleanly, and body must recover.
await expect(dialog).not.toBeVisible({ timeout: 5_000 });
// Give Radix's exit animation + cleanup a comfortable margin on top of
// the ~300ms simulated network delay.
await waitForBodyInteractive(frigateApp.page, 5_000);
await expectBodyInteractive(frigateApp.page);
// User-visible confirmation: click something outside the dialog
// and assert it actually responds.
const librarySelector = frigateApp.page
.getByRole("button")
.filter({ hasText: /\(\d+\)/ })
.first();
await librarySelector.click();
await expect(
frigateApp.page
.locator('[role="menu"], [data-radix-menu-content]')
.first(),
).toBeVisible({ timeout: 3_000 });
});
});
test.describe("Face Library — mobile @high @mobile", () => {

View File

@ -257,7 +257,6 @@
"export": "Export",
"actions": "Actions",
"uiPlayground": "UI Playground",
"features": "Features",
"faceLibrary": "Face Library",
"classification": "Classification",
"chat": "Chat",

View File

@ -457,13 +457,7 @@
"enableDesc": "Temporarily disable an enabled camera until Frigate restarts. Disabling a camera completely stops Frigate's processing of this camera's streams. Detection, recording, and debugging will be unavailable.<br /> <em>Note: This does not disable go2rtc restreams.</em>",
"disableLabel": "Disabled cameras",
"disableDesc": "Enable a camera that is currently not visible in the UI and disabled in the configuration. A restart of Frigate is required after enabling.",
"enableSuccess": "Enabled {{cameraName}} in configuration. Restart Frigate to apply the changes.",
"friendlyName": {
"edit": "Edit camera display name",
"title": "Edit Display Name",
"description": "Set the friendly name shown for this camera throughout the Frigate UI. Leave blank to use the camera ID.",
"rename": "Rename"
}
"enableSuccess": "Enabled {{cameraName}} in configuration. Restart Frigate to apply the changes."
},
"cameraConfig": {
"add": "Add Camera",

View File

@ -161,13 +161,13 @@ export function AnimatedEventCard({
<TooltipTrigger asChild>
<Button
className={cn(
"absolute left-2 top-1 z-40 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 transition-opacity",
"absolute left-2 top-1 z-40 transition-opacity",
threatLevel === ThreatLevel.SECURITY_CONCERN &&
"pointer-events-auto opacity-100",
"pointer-events-auto bg-severity_alert opacity-100 hover:bg-severity_alert",
threatLevel === ThreatLevel.NEEDS_REVIEW &&
"pointer-events-auto opacity-100",
"pointer-events-auto bg-severity_detection opacity-100 hover:bg-severity_detection",
threatLevel === ThreatLevel.NORMAL &&
"pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100",
"pointer-events-none bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 opacity-0 group-hover:pointer-events-auto group-hover:opacity-100",
)}
size="xs"
aria-label={t("markAsReviewed")}

View File

@ -155,40 +155,14 @@ export function MessageBubble({
) : (
<div
className={cn(
"[&>*:last-child]:inline",
!isComplete &&
"[&>p:last-child]:inline after:ml-0.5 after:inline-block after:h-4 after:w-2 after:animate-cursor-blink after:rounded-sm after:bg-foreground after:align-middle after:content-['']",
"after:ml-0.5 after:inline-block after:h-4 after:w-2 after:animate-cursor-blink after:rounded-sm after:bg-foreground after:align-middle after:content-['']",
)}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ node: _n, ...props }) => (
<p className="my-2 first:mt-0 last:mb-0" {...props} />
),
ul: ({ node: _n, ...props }) => (
<ul
className="my-2 list-disc space-y-1 pl-6 first:mt-0 last:mb-0"
{...props}
/>
),
ol: ({ node: _n, ...props }) => (
<ol
className="my-2 list-decimal space-y-1 pl-6 first:mt-0 last:mb-0"
{...props}
/>
),
li: ({ node: _n, ...props }) => (
<li className="pl-1" {...props} />
),
code: ({ node: _n, className, ...props }) => (
<code
className={cn(
"rounded bg-foreground/10 px-1 py-0.5 font-mono text-sm",
className,
)}
{...props}
/>
),
table: ({ node: _n, ...props }) => (
<table
className="my-2 w-full border-collapse border border-border"

View File

@ -14,6 +14,7 @@ import Step3ChooseExamples, {
Step3FormData,
} from "./wizard/Step3ChooseExamples";
import { cn } from "@/lib/utils";
import { isDesktop } from "react-device-detect";
import axios from "axios";
const OBJECT_STEPS = [
@ -152,9 +153,13 @@ export default function ClassificationModelWizardDialog({
>
<DialogContent
className={cn(
"scrollbar-container max-h-[90%] overflow-y-auto",
wizardState.currentStep == 0 && "xl:max-h-[80%]",
wizardState.currentStep > 0 && "md:max-w-[70%] xl:max-h-[80%]",
"",
isDesktop &&
wizardState.currentStep == 0 &&
"max-h-[90%] overflow-y-auto xl:max-h-[80%]",
isDesktop &&
wizardState.currentStep > 0 &&
"max-h-[90%] max-w-[70%] overflow-y-auto xl:max-h-[80%]",
)}
onInteractOutside={(e) => {
e.preventDefault();

View File

@ -65,14 +65,10 @@ import {
globalCameraDefaultSections,
buildOverrides,
buildConfigDataForPath,
flattenOverrides,
getBaseCameraSectionValue,
sanitizeSectionData as sharedSanitizeSectionData,
requiresRestartForOverrides as sharedRequiresRestartForOverrides,
} from "@/utils/configUtil";
import SaveAllPreviewPopover, {
type SaveAllPreviewItem,
} from "@/components/overlay/detail/SaveAllPreviewPopover";
import RestartDialog from "@/components/overlay/dialog/RestartDialog";
import { useRestart } from "@/api/ws";
import type {
@ -917,34 +913,6 @@ export function ConfigSection({
);
}, [sectionConfig?.renderers, sectionPath, cameraName, setPendingData]);
// Build a flat list of pending field changes for this section only.
// Mirrors the global Save All preview but scoped to the current section so
// users can inspect what will be saved without leaving the section.
const sectionPreviewItems = useMemo<SaveAllPreviewItem[]>(() => {
if (!hasChanges) return [];
if (!effectiveOverrides || typeof effectiveOverrides !== "object") {
return [];
}
const flattened = flattenOverrides(effectiveOverrides as JsonValue);
return flattened.map(({ path, value }) => ({
scope: effectiveLevel,
cameraName,
profileName: profileName
? (profileFriendlyName ?? profileName)
: undefined,
fieldPath: path ? `${sectionPath}.${path}` : sectionPath,
value,
}));
}, [
hasChanges,
effectiveOverrides,
effectiveLevel,
cameraName,
profileName,
profileFriendlyName,
sectionPath,
]);
if (!modifiedSchema) {
return null;
}
@ -1050,12 +1018,6 @@ export function ConfigSection({
defaultValue: "You have unsaved changes",
})}
</span>
<SaveAllPreviewPopover
items={sectionPreviewItems}
className="h-7 w-7"
align="start"
side="top"
/>
</div>
)}
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center md:w-auto">

View File

@ -6,7 +6,6 @@ import {
LuLifeBuoy,
LuList,
LuLogOut,
LuMessageSquare,
LuMoon,
LuSquarePen,
LuScanFace,
@ -483,25 +482,21 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
</Link>
</>
)}
</DropdownMenuGroup>
{isMobile && isAdmin && (
<>
<DropdownMenuLabel className="mt-1">
{t("menu.features")}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup className="flex flex-col">
{config?.face_recognition.enabled && (
<Link to="/faces">
<MenuItem
className="flex w-full items-center p-2 text-sm"
aria-label={t("menu.faceLibrary")}
>
<LuScanFace className="mr-2 size-4" />
<span>{t("menu.faceLibrary")}</span>
</MenuItem>
</Link>
)}
{isAdmin && isMobile && config?.face_recognition.enabled && (
<>
<Link to="/faces">
<MenuItem
className="flex w-full items-center p-2 text-sm"
aria-label={t("menu.faceLibrary")}
>
<LuScanFace className="mr-2 size-4" />
<span>{t("menu.faceLibrary")}</span>
</MenuItem>
</Link>
</>
)}
{isAdmin && isMobile && (
<>
<Link to="/classification">
<MenuItem
className="flex w-full items-center p-2 text-sm"
@ -511,20 +506,9 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
<span>{t("menu.classification")}</span>
</MenuItem>
</Link>
{config?.genai?.model !== "none" && (
<Link to="/chat">
<MenuItem
className="flex w-full items-center p-2 text-sm"
aria-label={t("menu.chat")}
>
<LuMessageSquare className="mr-2 size-4" />
<span>{t("menu.chat")}</span>
</MenuItem>
</Link>
)}
</DropdownMenuGroup>
</>
)}
</>
)}
</DropdownMenuGroup>
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
{t("menu.appearance")}
</DropdownMenuLabel>

View File

@ -124,7 +124,7 @@ export default function ClassificationSelectionDialog({
/>
<Tooltip>
<Selector {...(isDesktop ? { modal: false } : {})}>
<Selector>
<SelectorTrigger asChild>
<TooltipTrigger asChild={isChildButton}>{children}</TooltipTrigger>
</SelectorTrigger>

View File

@ -85,7 +85,7 @@ export default function FaceSelectionDialog({
)}
<Tooltip>
<Selector {...(isDesktop ? { modal: false } : {})}>
<Selector>
<SelectorTrigger asChild>
<TooltipTrigger asChild={isChildButton}>{children}</TooltipTrigger>
</SelectorTrigger>

View File

@ -391,8 +391,10 @@ export default function MobileReviewSettingsDrawer({
className="flex w-full items-center justify-center gap-2"
aria-label={t("title", { ns: "views/replay" })}
onClick={() => {
const now = new Date(latestTime * 1000);
now.setHours(now.getHours() - 1);
setDebugReplayRange({
after: latestTime - 60,
after: now.getTime() / 1000,
before: latestTime,
});
setSelectedReplayOption("1");
@ -539,9 +541,11 @@ export default function MobileReviewSettingsDrawer({
return;
}
const minutes = parseInt(option, 10);
const hours = parseInt(option);
const end = latestTime;
setDebugReplayRange({ after: end - minutes * 60, before: end });
const now = new Date(end * 1000);
now.setHours(now.getHours() - hours);
setDebugReplayRange({ after: now.getTime() / 1000, before: end });
};
content = (

View File

@ -1,4 +1,3 @@
import ActivityIndicator from "@/components/indicators/activity-indicator";
import TextEntry from "@/components/input/TextEntry";
import { Button } from "@/components/ui/button";
import {
@ -20,9 +19,7 @@ type TextEntryDialogProps = {
setOpen: (open: boolean) => void;
onSave: (text: string) => void;
defaultValue?: string;
placeholder?: string;
allowEmpty?: boolean;
isSaving?: boolean;
regexPattern?: RegExp;
regexErrorMessage?: string;
forbiddenPattern?: RegExp;
@ -36,9 +33,7 @@ export default function TextEntryDialog({
setOpen,
onSave,
defaultValue = "",
placeholder,
allowEmpty = false,
isSaving = false,
regexPattern,
regexErrorMessage,
forbiddenPattern,
@ -55,7 +50,6 @@ export default function TextEntryDialog({
</DialogHeader>
<TextEntry
defaultValue={defaultValue}
placeholder={placeholder}
allowEmpty={allowEmpty}
onSave={onSave}
regexPattern={regexPattern}
@ -64,22 +58,11 @@ export default function TextEntryDialog({
forbiddenErrorMessage={forbiddenErrorMessage}
>
<DialogFooter className={cn("pt-4", isMobile && "gap-2")}>
<Button
type="button"
disabled={isSaving}
onClick={() => setOpen(false)}
>
<Button type="button" onClick={() => setOpen(false)}>
{t("button.cancel")}
</Button>
<Button variant="select" type="submit" disabled={isSaving}>
{isSaving ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator className="size-4" />
<span>{t("button.saving")}</span>
</div>
) : (
t("button.save")
)}
<Button variant="select" type="submit">
{t("button.save")}
</Button>
</DialogFooter>
</TextEntry>

View File

@ -396,6 +396,7 @@ export default function HlsVideoPlayer({
}}
>
<ObjectTrackOverlay
key={`overlay-${currentTime}`}
camera={camera}
showBoundingBoxes={!isPlaying}
currentTime={currentTime}

View File

@ -57,7 +57,6 @@ import { useTranslation } from "react-i18next";
import { IoMdArrowRoundBack } from "react-icons/io";
import {
LuDownload,
LuFolderPlus,
LuFolderX,
LuPencil,
@ -778,76 +777,54 @@ function Exports() {
filters={["cameras"]}
onUpdateFilter={setExportFilter}
/>
<div className="flex items-center gap-1 md:gap-2">
{(exportsByCase[selectedCase.id]?.length ?? 0) > 0 && (
{isAdmin && (
<div className="flex items-center gap-1 md:gap-2">
<Button
asChild
className="flex items-center gap-2 p-2"
size="sm"
aria-label={t("button.download", { ns: "common" })}
aria-label={t("toolbar.addExport")}
onClick={() => setCaseForAddExport(selectedCase)}
>
<a
download
href={`${baseUrl}api/cases/${selectedCase.id}/download`}
>
<LuDownload className="text-secondary-foreground" />
{!isMobile && (
<div className="text-primary">
{t("button.download", { ns: "common" })}
</div>
)}
</a>
<LuPlus className="text-secondary-foreground" />
{!isMobile && (
<div className="text-primary">
{t("toolbar.addExport")}
</div>
)}
</Button>
)}
{isAdmin && (
<>
<Button
className="flex items-center gap-2 p-2"
size="sm"
aria-label={t("toolbar.addExport")}
onClick={() => setCaseForAddExport(selectedCase)}
>
<LuPlus className="text-secondary-foreground" />
{!isMobile && (
<div className="text-primary">
{t("toolbar.addExport")}
</div>
)}
</Button>
<Button
className="flex items-center gap-2 p-2"
size="sm"
aria-label={t("toolbar.editCase")}
onClick={() =>
setCaseDialog({
mode: "edit",
exportCase: selectedCase,
})
}
>
<LuPencil className="text-secondary-foreground" />
{!isMobile && (
<div className="text-primary">
{t("toolbar.editCase")}
</div>
)}
</Button>
<Button
className="flex items-center gap-2 p-2"
size="sm"
aria-label={t("toolbar.deleteCase")}
onClick={() => setCaseToDelete(selectedCase)}
>
<LuTrash2 className="text-secondary-foreground" />
{!isMobile && (
<div className="text-primary">
{t("toolbar.deleteCase")}
</div>
)}
</Button>
</>
)}
</div>
<Button
className="flex items-center gap-2 p-2"
size="sm"
aria-label={t("toolbar.editCase")}
onClick={() =>
setCaseDialog({
mode: "edit",
exportCase: selectedCase,
})
}
>
<LuPencil className="text-secondary-foreground" />
{!isMobile && (
<div className="text-primary">
{t("toolbar.editCase")}
</div>
)}
</Button>
<Button
className="flex items-center gap-2 p-2"
size="sm"
aria-label={t("toolbar.deleteCase")}
onClick={() => setCaseToDelete(selectedCase)}
>
<LuTrash2 className="text-secondary-foreground" />
{!isMobile && (
<div className="text-primary">
{t("toolbar.deleteCase")}
</div>
)}
</Button>
</div>
)}
</div>
)}
</>

View File

@ -28,7 +28,11 @@ import useOptimisticState from "@/hooks/use-optimistic-state";
import { isMobile } from "react-device-detect";
import { FaVideo } from "react-icons/fa";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import type { ConfigSectionData, JsonObject } from "@/types/configForm";
import type {
ConfigSectionData,
JsonObject,
JsonValue,
} from "@/types/configForm";
import useSWR from "swr";
import FilterSwitch from "@/components/filter/FilterSwitch";
import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
@ -89,7 +93,6 @@ import { mutate } from "swr";
import { RJSFSchema } from "@rjsf/utils";
import {
buildConfigDataForPath,
flattenOverrides,
parseProfileFromSectionPath,
prepareSectionSavePayload,
PROFILE_ELIGIBLE_SECTIONS,
@ -187,6 +190,25 @@ const parsePendingDataKey = (pendingDataKey: string) => {
};
};
const flattenOverrides = (
value: JsonValue | undefined,
path: string[] = [],
): Array<{ path: string; value: JsonValue }> => {
if (value === undefined) return [];
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return [{ path: path.join("."), value }];
}
const entries = Object.entries(value);
if (entries.length === 0) {
return [{ path: path.join("."), value: {} }];
}
return entries.flatMap(([key, entryValue]) =>
flattenOverrides(entryValue, [...path, key]),
);
};
const createSectionPage = (
sectionKey: string,
level: "global" | "camera",

View File

@ -219,32 +219,6 @@ export function buildOverrides(
return current;
}
// ---------------------------------------------------------------------------
// flattenOverrides — turn an overrides object into a list of leaf paths
// ---------------------------------------------------------------------------
// Walks a nested overrides value and produces a flat list of `{ path, value }`
// entries, one per leaf. Used by save/preview UIs to enumerate the individual
// fields that will be changed.
export function flattenOverrides(
value: JsonValue | undefined,
path: string[] = [],
): Array<{ path: string; value: JsonValue }> {
if (value === undefined) return [];
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return [{ path: path.join("."), value }];
}
const entries = Object.entries(value);
if (entries.length === 0) {
return [{ path: path.join("."), value: {} }];
}
return entries.flatMap(([key, entryValue]) =>
flattenOverrides(entryValue, [...path, key]),
);
}
// ---------------------------------------------------------------------------
// sanitizeSectionData — normalize config values and strip hidden fields
// ---------------------------------------------------------------------------

View File

@ -728,8 +728,10 @@ export function RecordingView({
setShareTimestampOpen(true);
}}
onDebugReplayClick={() => {
const now = new Date(timeRange.before * 1000);
now.setHours(now.getHours() - 1);
setDebugReplayRange({
after: timeRange.before - 60,
after: now.getTime() / 1000,
before: timeRange.before,
});
setDebugReplayMode("select");

View File

@ -14,7 +14,7 @@ import { useTranslation } from "react-i18next";
import CameraEditForm from "@/components/settings/CameraEditForm";
import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
import DeleteCameraDialog from "@/components/overlay/dialog/DeleteCameraDialog";
import { LuPencil, LuPlus, LuTrash2 } from "react-icons/lu";
import { LuPlus, LuTrash2 } from "react-icons/lu";
import { IoMdArrowRoundBack } from "react-icons/io";
import { isDesktop } from "react-device-detect";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
@ -26,12 +26,6 @@ import axios from "axios";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import RestartDialog from "@/components/overlay/dialog/RestartDialog";
import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator";
import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { ProfileState } from "@/types/profile";
import { getProfileColor } from "@/utils/profileColors";
import { cn } from "@/lib/utils";
@ -167,13 +161,7 @@ export default function CameraManagementView({
key={camera}
className="flex flex-row items-center justify-between"
>
<div className="flex items-center gap-1">
<CameraNameLabel camera={camera} />
<CameraFriendlyNameEditor
cameraName={camera}
onConfigChanged={updateConfig}
/>
</div>
<CameraNameLabel camera={camera} />
<CameraEnableSwitch cameraName={camera} />
</div>
))}
@ -309,103 +297,6 @@ function CameraEnableSwitch({ cameraName }: CameraEnableSwitchProps) {
);
}
type CameraFriendlyNameEditorProps = {
cameraName: string;
onConfigChanged: () => Promise<unknown>;
};
function CameraFriendlyNameEditor({
cameraName,
onConfigChanged,
}: CameraFriendlyNameEditorProps) {
const { t } = useTranslation(["views/settings", "common"]);
const { data: config } = useSWR<FrigateConfig>("config");
const [open, setOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const currentFriendlyName = config?.cameras?.[cameraName]?.friendly_name;
const onSave = useCallback(
async (text: string) => {
if (isSaving) return;
setIsSaving(true);
try {
await axios.put("config/set", {
requires_restart: 0,
config_data: {
cameras: {
[cameraName]: {
friendly_name: text.trim() || null,
},
},
},
});
await onConfigChanged();
setOpen(false);
toast.success(t("toast.save.success", { ns: "common" }), {
position: "top-center",
});
} catch (error) {
const errorMessage =
axios.isAxiosError(error) &&
(error.response?.data?.message || error.response?.data?.detail)
? error.response?.data?.message || error.response?.data?.detail
: t("toast.save.error.noMessage", { ns: "common" });
toast.error(
t("toast.save.error.title", { errorMessage, ns: "common" }),
{ position: "top-center" },
);
} finally {
setIsSaving(false);
}
},
[cameraName, isSaving, onConfigChanged, t],
);
const renameLabel = t("cameraManagement.streams.friendlyName.rename", {
ns: "views/settings",
});
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7"
aria-label={renameLabel}
onClick={() => setOpen(true)}
disabled={isSaving}
>
<LuPencil className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{renameLabel}</TooltipContent>
</Tooltip>
<TextEntryDialog
open={open}
setOpen={setOpen}
title={t("cameraManagement.streams.friendlyName.title", {
ns: "views/settings",
})}
description={t("cameraManagement.streams.friendlyName.description", {
ns: "views/settings",
})}
defaultValue={currentFriendlyName ?? ""}
placeholder={currentFriendlyName ? undefined : cameraName}
allowEmpty
isSaving={isSaving}
onSave={onSave}
/>
</>
);
}
type CameraConfigEnableSwitchProps = {
cameraName: string;
setRestartDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;

View File

@ -10,16 +10,13 @@ import axios from "axios";
import { toast } from "sonner";
import { useJobStatus } from "@/api/ws";
import { Switch } from "@/components/ui/switch";
import { LuCheck, LuExternalLink, LuX } from "react-icons/lu";
import { LuCheck, LuX } from "react-icons/lu";
import { cn } from "@/lib/utils";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { MediaSyncResults, MediaSyncStats } from "@/types/ws";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { Link } from "react-router-dom";
export default function MediaSyncSettingsView() {
const { t } = useTranslation("views/settings");
const { getLocaleDocUrl } = useDocDomain();
const [selectedMediaTypes, setSelectedMediaTypes] = useState<string[]>([
"all",
]);
@ -112,25 +109,13 @@ export default function MediaSyncSettingsView() {
<Heading as="h4" className="mb-2 hidden md:block">
{t("maintenance.sync.title")}
</Heading>
<div className="max-w-6xl">
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-muted-foreground">
<p>{t("maintenance.sync.desc")}</p>
<div className="flex items-center text-primary-variant">
<Link
to={getLocaleDocUrl(
"configuration/record#syncing-media-files-with-disk",
)}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
</div>
<div className="space-y-6">
{/* Media Types Selection */}
<div>