Compare commits

...
Author SHA1 Message Date
Josh Hawkins 3a2f741934 fix untrained classification models not saving images
The mypy cleanup in https://github.com/blakeblackshear/frigate/pull/22658 added a check on the tensor details to the top of process_frame for both state and object models. Those are unset until a model is trained, so untrained models returned early and never saved images to Recent Classifications. Remove the check from process_frame, the classify methods already check the tensor details before using them.
2026-10-08 14:51:32 -05:00
Nicolas MowenandGitHub f929134a1b Revise embedding model details in config.md (#24586)
Updated model descriptions for embeddings in the configuration documentation.
2026-10-07 10:22:47 -05:00
Josh HawkinsandGitHub cb745d0a50 Miscellaneous fixes (0.18) (#24567)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
* run recording snapshot ffmpeg off the event loop

The recording snapshot and Frigate+ recording submit endpoints called get_image_from_recording inline, which is a blocking ffmpeg subprocess with no timeout, so every other request stalled until the frame was decoded. Both calls now run in asyncio.to_thread.

* fix camera info dialog overflowing on mobile

* adjust test
2026-10-05 11:26:08 -06:00
Josh HawkinsandGitHub e912d0852b Fix deleting the only camera when the cameras key has a trailing comment (#24542)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
2026-10-02 06:58:57 -06:00
7 changed files with 138 additions and 22 deletions
+4 -3
View File
@@ -73,9 +73,10 @@ You must use a vision-capable model with Frigate. The following models are recom
The `embeddings` role needs a different kind of model. Text queries are matched against the stored image embeddings, so the model must be trained to place images and text into the same vector space. A chat or description model will still return vectors when asked, but those vectors are not trained for retrieval and text searches will return poor matches with no error to indicate why.
| Model | Notes |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `qwen3-vl-embedding` | Multimodal embeddings for [Semantic Search](/configuration/semantic_search#genai-provider). Must be served by llama.cpp started with `--embeddings` and `--mmproj`. |
| Model | Notes |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `embeddinggemma-2` | Multimodal embeddings for [Semantic Search](/configuration/semantic_search#genai-provider). Strong semantic search accuracy with efficient inference on a small model. |
| `qwen3-vl-embedding` | Multimodal embeddings for [Semantic Search](/configuration/semantic_search#genai-provider). Good performance, large model that requires strong hardware for inference. |
:::info
+6 -1
View File
@@ -35,7 +35,11 @@ from frigate.config.camera.updater import (
)
from frigate.config.env import substitute_frigate_vars
from frigate.models import User
from frigate.util.builtin import clean_camera_user_pass, get_record_segment_time
from frigate.util.builtin import (
clean_camera_user_pass,
clear_orphaned_comments,
get_record_segment_time,
)
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
from frigate.util.config import find_config_file
from frigate.util.image import run_ffmpeg_snapshot
@@ -1201,6 +1205,7 @@ async def delete_camera(
# Remove camera from config
if "cameras" in data and camera_name in data["cameras"]:
del data["cameras"][camera_name]
clear_orphaned_comments(data["cameras"], data, "cameras")
# Remove camera from auth roles
auth = data.get("auth", {})
+13 -4
View File
@@ -351,8 +351,13 @@ async def get_snapshot_from_recording(
mime_type = "png" if format == "png" else "jpeg"
config: FrigateConfig = request.app.frigate_config
image_data = get_image_from_recording(
config.ffmpeg, recording.path, time_in_segment, codec, height
image_data = await asyncio.to_thread(
get_image_from_recording,
config.ffmpeg,
recording.path,
time_in_segment,
codec,
height,
)
if not image_data:
@@ -406,8 +411,12 @@ async def submit_recording_snapshot_to_plus(
config: FrigateConfig = request.app.frigate_config
recording: Recordings = recording_query.get()
time_in_segment = frame_time - recording.start_time
image_data = get_image_from_recording(
config.ffmpeg, recording.path, time_in_segment, "png"
image_data = await asyncio.to_thread(
get_image_from_recording,
config.ffmpeg,
recording.path,
time_in_segment,
"png",
)
if not image_data:
@@ -170,12 +170,7 @@ class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi):
return None
def process_frame(self, frame_data: dict[str, Any], frame: np.ndarray) -> None:
if (
not self.model_config.name
or not self.model_config.state_config
or not self.tensor_input_details
or not self.tensor_output_details
):
if not self.model_config.name or not self.model_config.state_config:
return
if self.metrics and self.model_config.name in self.metrics.classification_cps:
@@ -515,12 +510,7 @@ class CustomObjectClassificationProcessor(DeferredRealtimeProcessorApi):
return best_label, avg_score
def process_frame(self, obj_data: dict[str, Any], frame: np.ndarray) -> None:
if (
not self.model_config.name
or not self.model_config.object_config
or not self.tensor_input_details
or not self.tensor_output_details
):
if not self.model_config.name or not self.model_config.object_config:
return
if self.metrics and self.model_config.name in self.metrics.classification_cps:
+90
View File
@@ -1,6 +1,9 @@
"""Unit tests for recordings/media API endpoints."""
import threading
from concurrent.futures import ThreadPoolExecutor
from datetime import UTC, datetime
from unittest.mock import patch
import pytz
from fastapi import Request
@@ -527,3 +530,90 @@ class TestHttpMedia(BaseTestHttp):
assert response.status_code == 200
assert response.json() == [{"start_time": 1010, "end_time": 1030}]
def _insert_recording(self, id: str, start_time: float, end_time: float) -> None:
Recordings.insert(
id=id,
path=f"/media/recordings/{id}.mp4",
camera="front_door",
start_time=start_time,
end_time=end_time,
duration=end_time - start_time,
motion=0,
).execute()
def test_recording_snapshot_does_not_block_event_loop(self):
self._insert_recording("snapshot", 1000, 1010)
started = threading.Event()
release = threading.Event()
finished = threading.Event()
def slow_image(*args):
started.set()
release.wait(timeout=5)
finished.set()
return b"jpeg"
with (
AuthTestClient(self.app) as client,
patch("frigate.api.media.get_image_from_recording", slow_image),
ThreadPoolExecutor(max_workers=1) as executor,
):
snapshot = executor.submit(
client.get, "/front_door/recordings/1005/snapshot.jpg"
)
self.assertTrue(started.wait(timeout=2))
version = client.get("/version")
blocked = finished.is_set()
release.set()
self.assertEqual(version.status_code, 200)
self.assertFalse(blocked)
self.assertEqual(snapshot.result().status_code, 200)
def test_recording_snapshot_hit_returns_image(self):
self._insert_recording("snapshot", 1000, 1010)
with (
AuthTestClient(self.app) as client,
patch("frigate.api.media.get_image_from_recording", return_value=b"jpeg"),
):
response = client.get("/front_door/recordings/1005/snapshot.jpg")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], "image/jpeg")
self.assertEqual(response.content, b"jpeg")
def test_recording_snapshot_misses_return_404(self):
self._insert_recording("snapshot", 1000, 1010)
with AuthTestClient(self.app) as client:
absent = client.get("/front_door/recordings/2000/snapshot.jpg")
rounded_miss = client.get("/front_door/recordings/1010.5/snapshot.jpg")
self.assertEqual(absent.status_code, 404)
self.assertEqual(rounded_miss.status_code, 404)
def test_plus_snapshot_miss_returns_404_without_image_lookup(self):
with (
AuthTestClient(self.app) as client,
patch("frigate.api.media.get_image_from_recording") as image,
):
response = client.post("/front_door/plus/1005")
self.assertEqual(response.status_code, 404)
image.assert_not_called()
def test_plus_snapshot_hit_uploads_image(self):
self._insert_recording("snapshot", 1000, 1010)
with (
AuthTestClient(self.app) as client,
patch("frigate.api.media.get_image_from_recording", return_value=b"png"),
patch("frigate.api.media.cv2.imdecode", return_value="frame"),
patch.object(self.app.frigate_config.plus_api, "upload_image") as upload,
):
response = client.post("/front_door/plus/1005")
self.assertEqual(response.status_code, 200)
upload.assert_called_once_with("frame", "front_door")
+22 -1
View File
@@ -6,7 +6,7 @@ import unittest
from ruamel.yaml import YAML
from frigate.util.builtin import update_yaml_file_bulk
from frigate.util.builtin import clear_orphaned_comments, update_yaml_file_bulk
class TestUpdateYaml(unittest.TestCase):
@@ -178,6 +178,27 @@ class TestUpdateYaml(unittest.TestCase):
assert data["cameras"]["cam1"]["detect"]["fps"] == 5
assert "# tuned for the pi" in self._read()
def test_delete_only_camera_with_comment_on_cameras_key(self):
"""Deleting the only camera stays parseable when the cameras key
carries the default config's trailing comment."""
self._write(
"cameras: # No cameras defined, UI wizard should be used\n"
" cam1:\n"
" enabled: true\n"
"version: 0.18-0\n"
)
yaml = YAML()
with open(self.config_path) as f:
data = yaml.load(f)
del data["cameras"]["cam1"]
clear_orphaned_comments(data["cameras"], data, "cameras")
with open(self.config_path, "w") as f:
yaml.dump(data, f)
data = self._load()
assert data["cameras"] == {}
assert data["version"] == "0.18-0"
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -91,7 +91,7 @@ export default function CameraInfoDialog({
open={showCameraInfoDialog}
onOpenChange={setShowCameraInfoDialog}
>
<DialogContent>
<DialogContent className="scrollbar-container max-h-[90dvh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="smart-capitalize">
{t("cameras.info.cameraProbeInfo", {