Improve System Health pane (#24188)

* build out system health pane

* tweaks

* fixes

* fix notice link so it opens the correct camera

* tweak language
This commit is contained in:
Josh Hawkins
2026-09-12 07:30:04 -06:00
committed by Nicolas Mowen
parent 6d33b31bc6
commit 70ce193e09
57 changed files with 3965 additions and 464 deletions
+3 -1
View File
@@ -302,7 +302,9 @@ def ffprobe(request: Request, paths: str = "", detailed: bool = False):
stderr_decoded = str(ffprobe.stderr)
stderr_lines = [
line.strip() for line in stderr_decoded.split("\n") if line.strip()
clean_camera_user_pass(line.strip())
for line in stderr_decoded.split("\n")
if line.strip()
]
result = {
+2
View File
@@ -28,6 +28,7 @@ class DataProcessorMetrics:
object_desc_dps: ValueProxy[float]
classification_speeds: DictProxy[str, ValueProxy[float]]
classification_cps: DictProxy[str, ValueProxy[float]]
runtime_devices: DictProxy[str, str]
def __init__(self, manager: SyncManager, custom_classification_models: list[str]):
self.image_embeddings_speed = manager.Value("d", 0.0)
@@ -46,6 +47,7 @@ class DataProcessorMetrics:
self.object_desc_dps = manager.Value("d", 0.0)
self.classification_speeds = manager.dict()
self.classification_cps = manager.dict()
self.runtime_devices = manager.dict()
if custom_classification_models:
for key in custom_classification_models:
+109 -15
View File
@@ -18,6 +18,36 @@ logger = logging.getLogger(__name__)
# Process-wide lock serializing all OpenVINO compile/inference calls
_OPENVINO_LOCK = threading.Lock()
# model file path -> (model type, device the runner actually loaded on); the
# embeddings maintainer folds this per enrichment for the stats endpoint.
# Models load on several threads (reindex, lazy first use), so writes and
# snapshots go through the lock.
loaded_devices: dict[str, tuple[str, str]] = {}
_loaded_devices_lock = threading.Lock()
def record_loaded_device(model_path: str, model_type: str, device: str) -> None:
"""Record the device a model loaded on, for the enrichment stats."""
with _loaded_devices_lock:
loaded_devices[model_path] = (model_type, device)
logger.info("Loaded %s model on %s", model_type, device)
def snapshot_loaded_devices() -> dict[str, tuple[str, str]]:
"""A copy that is safe to iterate while other threads load models."""
with _loaded_devices_lock:
return dict(loaded_devices)
_PROVIDER_LABELS = {
"CUDAExecutionProvider": "CUDA",
"TensorrtExecutionProvider": "TensorRT",
"MIGraphXExecutionProvider": "MIGraphX",
"OpenVINOExecutionProvider": "OpenVINO",
"CPUExecutionProvider": "CPU",
}
def is_arm64_platform() -> bool:
"""Check if we're running on an ARM platform."""
@@ -117,6 +147,12 @@ class BaseModelRunner(ABC):
"""Run inference with the model."""
pass
@property
@abstractmethod
def device_name(self) -> str:
"""Short label of the device the model actually loaded on."""
pass
class ONNXModelRunner(BaseModelRunner):
"""Run ONNX models using ONNX Runtime."""
@@ -174,6 +210,18 @@ class ONNXModelRunner(BaseModelRunner):
return self.ort.run(None, input)
@property
def device_name(self) -> str:
providers = self.ort.get_providers()
if not providers:
return "CPU"
provider = providers[0]
return _PROVIDER_LABELS.get(
provider, provider.removesuffix("ExecutionProvider")
)
class CudaGraphRunner(BaseModelRunner):
"""Encapsulates CUDA Graph capture and replay using ONNX Runtime IOBinding.
@@ -261,6 +309,10 @@ class CudaGraphRunner(BaseModelRunner):
self._session.run_with_iobinding(self._io_binding, ro)
return self._io_binding.copy_outputs_to_cpu()
@property
def device_name(self) -> str:
return "CUDA"
class OpenVINOModelRunner(BaseModelRunner):
"""OpenVINO model runner that handles inference efficiently."""
@@ -337,6 +389,8 @@ class OpenVINOModelRunner(BaseModelRunner):
if device == "NPU" and OpenVINOModelRunner.is_detection_model(model_type):
compile_config["NPU_TURBO"] = "YES"
self.compiled_device = device
# Compile model under the shared lock
with _OPENVINO_LOCK:
try:
@@ -368,6 +422,22 @@ class OpenVINOModelRunner(BaseModelRunner):
# model is complex and has dynamic shape
pass
@property
def device_name(self) -> str:
device = self.compiled_device
if device == "AUTO":
try:
resolved = self.compiled_model.get_property("EXECUTION_DEVICES")
if resolved:
device = ",".join(str(d) for d in resolved)
except Exception:
# older OpenVINO builds do not expose the property
pass
return f"OpenVINO {device}"
def get_input_names(self) -> list[str]:
"""Get input names for the model."""
return [input.get_any_name() for input in self.compiled_model.inputs]
@@ -515,6 +585,10 @@ class RKNNModelRunner(BaseModelRunner):
logger.error(f"Error loading RKNN model: {e}")
raise
@property
def device_name(self) -> str:
return "RKNN"
def get_input_names(self) -> list[str]:
"""Get input names for the model."""
# For detection models, we typically use "input" as the default input name
@@ -595,6 +669,14 @@ class RKNNModelRunner(BaseModelRunner):
pass
def _record_runner(
model_path: str, model_type: str, runner: BaseModelRunner
) -> BaseModelRunner:
"""Record the device a freshly loaded runner ended up on."""
record_loaded_device(model_path, model_type, runner.device_name)
return runner
def get_optimized_runner(
model_path: str, device: str | None, model_type: str, **kwargs
) -> BaseModelRunner:
@@ -605,7 +687,7 @@ def get_optimized_runner(
rknn_path = auto_convert_model(model_path)
if rknn_path:
return RKNNModelRunner(rknn_path)
return _record_runner(model_path, model_type, RKNNModelRunner(rknn_path))
providers, options = get_ort_providers(device == "CPU", device, **kwargs)
@@ -614,7 +696,11 @@ def get_optimized_runner(
# In other images we will get CUDA / ROCm which are preferred over OpenVINO
# There is currently no way to prioritize OpenVINO over CUDA / ROCm in these images
if device != "CPU" and is_openvino_gpu_npu_available():
return OpenVINOModelRunner(model_path, device, model_type, **kwargs)
return _record_runner(
model_path,
model_type,
OpenVINOModelRunner(model_path, device, model_type, **kwargs),
)
if (
CudaGraphRunner.is_model_supported(model_type)
@@ -624,13 +710,17 @@ def get_optimized_runner(
**options[0],
"enable_cuda_graph": True,
}
return CudaGraphRunner(
ort.InferenceSession(
model_path,
providers=providers,
provider_options=options,
return _record_runner(
model_path,
model_type,
CudaGraphRunner(
ort.InferenceSession(
model_path,
providers=providers,
provider_options=options,
),
options[0]["device_id"],
),
options[0]["device_id"],
)
if (
@@ -642,12 +732,16 @@ def get_optimized_runner(
providers.pop(0)
options.pop(0)
return ONNXModelRunner(
ort.InferenceSession(
model_path,
sess_options=get_ort_session_options(model_type),
providers=providers,
provider_options=options,
return _record_runner(
model_path,
model_type,
ONNXModelRunner(
ort.InferenceSession(
model_path,
sess_options=get_ort_session_options(model_type),
providers=providers,
provider_options=options,
),
model_type=model_type,
),
model_type=model_type,
)
+17
View File
@@ -60,6 +60,8 @@ from frigate.data_processing.real_time.license_plate import (
)
from frigate.data_processing.types import DataProcessorMetrics, PostProcessDataEnum
from frigate.db.sqlitevecq import SqliteVecQueueDatabase
from frigate.detectors.detection_runners import snapshot_loaded_devices
from frigate.embeddings.types import fold_runtime_devices
from frigate.events.types import (
EventStateEnum,
EventTypeEnum,
@@ -101,6 +103,7 @@ class EmbeddingMaintainer(threading.Thread):
super().__init__(name="embeddings_maintainer")
self.config = config
self.metrics = metrics
self._published_devices: dict[str, str] = {}
self.embeddings = None
self.config_updater = CameraConfigUpdateSubscriber(
self.config,
@@ -337,6 +340,7 @@ class EmbeddingMaintainer(threading.Thread):
self._expire_dedicated_lpr()
self._process_finalized()
self._process_event_metadata()
self._publish_runtime_devices()
# Shutdown deferred processors
for processor in self.realtime_processors:
@@ -738,6 +742,19 @@ class EmbeddingMaintainer(threading.Thread):
if isinstance(processor, ReviewDescriptionProcessor):
processor.process_data(review_updates, PostProcessDataEnum.review)
def _publish_runtime_devices(self) -> None:
"""Push each enrichment's loaded device to the shared metrics dict.
Runs every loop iteration, so only changed entries cross the manager
boundary.
"""
for enrichment, device in fold_runtime_devices(
snapshot_loaded_devices()
).items():
if self._published_devices.get(enrichment) != device:
self.metrics.runtime_devices[enrichment] = device
self._published_devices[enrichment] = device
def _process_event_metadata(self):
# Check for regenerate description requests
(topic, payload) = self.event_metadata_subscriber.check_for_update()
+13 -5
View File
@@ -6,7 +6,10 @@ import os
import numpy as np
from frigate.const import MODEL_CACHE_DIR
from frigate.detectors.detection_runners import get_optimized_runner
from frigate.detectors.detection_runners import (
get_optimized_runner,
record_loaded_device,
)
from frigate.embeddings.types import EnrichmentModelTypeEnum
from frigate.log import suppress_stderr_during
from frigate.util.downloader import ModelDownloader
@@ -62,13 +65,18 @@ class FaceNetEmbedding(BaseEmbedding):
if self.downloader:
self.downloader.wait_for_download()
model_path = os.path.join(MODEL_CACHE_DIR, "facedet/facenet.tflite")
# Suppress TFLite delegate creation messages that bypass Python logging
with suppress_stderr_during("tflite_interpreter_init"):
self.runner = Interpreter(
model_path=os.path.join(MODEL_CACHE_DIR, "facedet/facenet.tflite"),
num_threads=2,
)
self.runner = Interpreter(model_path=model_path, num_threads=2)
self.runner.allocate_tensors()
# tflite never goes through get_optimized_runner, so the small face
# model would otherwise never report a device
record_loaded_device(
model_path, EnrichmentModelTypeEnum.facenet.value, "CPU"
)
self.tensor_input_details = self.runner.get_input_details()
self.tensor_output_details = self.runner.get_output_details()
+34
View File
@@ -13,3 +13,37 @@ class EnrichmentModelTypeEnum(str, Enum):
jina_v2 = "jina_v2"
paddleocr = "paddleocr"
yolov9_license_plate = "yolov9_license_plate"
# which enrichment each model type belongs to; the single place this lives
ENRICHMENT_FOR_MODEL_TYPE: dict[str, str] = {
EnrichmentModelTypeEnum.arcface.value: "face_recognition",
EnrichmentModelTypeEnum.facenet.value: "face_recognition",
EnrichmentModelTypeEnum.jina_v1.value: "semantic_search",
EnrichmentModelTypeEnum.jina_v2.value: "semantic_search",
EnrichmentModelTypeEnum.paddleocr.value: "lpr",
EnrichmentModelTypeEnum.yolov9_license_plate.value: "lpr",
}
def fold_runtime_devices(loaded: dict[str, tuple[str, str]]) -> dict[str, str]:
"""One device per enrichment from the per model registry.
A non CPU device wins so a model pinned to the CPU on purpose (the Jina V1
text model) does not hide the accelerator its sibling loaded on, while an
enrichment whose models all fell back to the CPU still reports CPU.
"""
folded: dict[str, str] = {}
for model_type, device in loaded.values():
enrichment = ENRICHMENT_FOR_MODEL_TYPE.get(model_type)
if enrichment is None:
continue
current = folded.get(enrichment)
if current is None or ("CPU" in current and "CPU" not in device):
folded[enrichment] = device
return folded
+83 -72
View File
@@ -136,6 +136,86 @@ def get_detector_stats(
return detector_stats
def embeddings_stats(
config: FrigateConfig, embeddings_metrics: DataProcessorMetrics | None
) -> dict[str, Any]:
"""Enrichment speed metrics plus the device each enrichment loaded on."""
stats: dict[str, Any] = {}
if not embeddings_metrics:
return stats
# Add metrics based on what's enabled
if config.semantic_search.enabled:
stats.update(
{
"image_embedding_speed": round(
embeddings_metrics.image_embeddings_speed.value * 1000, 2
),
"image_embedding": round(
embeddings_metrics.image_embeddings_eps.value, 2
),
"text_embedding_speed": round(
embeddings_metrics.text_embeddings_speed.value * 1000, 2
),
"text_embedding": round(
embeddings_metrics.text_embeddings_eps.value, 2
),
}
)
if config.face_recognition.enabled:
stats["face_recognition_speed"] = round(
embeddings_metrics.face_rec_speed.value * 1000, 2
)
stats["face_recognition"] = round(embeddings_metrics.face_rec_fps.value, 2)
if config.lpr.enabled:
stats["plate_recognition_speed"] = round(
embeddings_metrics.alpr_speed.value * 1000, 2
)
stats["plate_recognition"] = round(embeddings_metrics.alpr_pps.value, 2)
if embeddings_metrics.yolov9_lpr_pps.value > 0.0:
stats["yolov9_plate_detection_speed"] = round(
embeddings_metrics.yolov9_lpr_speed.value * 1000, 2
)
stats["yolov9_plate_detection"] = round(
embeddings_metrics.yolov9_lpr_pps.value, 2
)
if embeddings_metrics.review_desc_speed.value > 0.0:
stats["review_description_speed"] = round(
embeddings_metrics.review_desc_speed.value * 1000, 2
)
stats["review_description_events_per_second"] = round(
embeddings_metrics.review_desc_dps.value, 2
)
if embeddings_metrics.object_desc_speed.value > 0.0:
stats["object_description_speed"] = round(
embeddings_metrics.object_desc_speed.value * 1000, 2
)
stats["object_description_events_per_second"] = round(
embeddings_metrics.object_desc_dps.value, 2
)
for key in embeddings_metrics.classification_speeds.keys():
stats[f"{key}_classification_speed"] = round(
embeddings_metrics.classification_speeds[key].value * 1000, 2
)
stats[f"{key}_classification_events_per_second"] = round(
embeddings_metrics.classification_cps[key].value, 2
)
devices = dict(embeddings_metrics.runtime_devices)
if devices:
stats["devices"] = devices
return stats
def stats_snapshot(
config: FrigateConfig,
stats_tracking: StatsTrackingTypes,
@@ -209,78 +289,9 @@ def stats_snapshot(
stats["skipped_fps"] = round(total_skipped_fps, 2)
stats["detection_fps"] = round(total_detection_fps, 2)
stats["embeddings"] = {}
# Get metrics if available
embeddings_metrics = stats_tracking.get("embeddings_metrics")
if embeddings_metrics:
# Add metrics based on what's enabled
if config.semantic_search.enabled:
stats["embeddings"].update(
{
"image_embedding_speed": round(
embeddings_metrics.image_embeddings_speed.value * 1000, 2
),
"image_embedding": round(
embeddings_metrics.image_embeddings_eps.value, 2
),
"text_embedding_speed": round(
embeddings_metrics.text_embeddings_speed.value * 1000, 2
),
"text_embedding": round(
embeddings_metrics.text_embeddings_eps.value, 2
),
}
)
if config.face_recognition.enabled:
stats["embeddings"]["face_recognition_speed"] = round(
embeddings_metrics.face_rec_speed.value * 1000, 2
)
stats["embeddings"]["face_recognition"] = round(
embeddings_metrics.face_rec_fps.value, 2
)
if config.lpr.enabled:
stats["embeddings"]["plate_recognition_speed"] = round(
embeddings_metrics.alpr_speed.value * 1000, 2
)
stats["embeddings"]["plate_recognition"] = round(
embeddings_metrics.alpr_pps.value, 2
)
if embeddings_metrics.yolov9_lpr_pps.value > 0.0:
stats["embeddings"]["yolov9_plate_detection_speed"] = round(
embeddings_metrics.yolov9_lpr_speed.value * 1000, 2
)
stats["embeddings"]["yolov9_plate_detection"] = round(
embeddings_metrics.yolov9_lpr_pps.value, 2
)
if embeddings_metrics.review_desc_speed.value > 0.0:
stats["embeddings"]["review_description_speed"] = round(
embeddings_metrics.review_desc_speed.value * 1000, 2
)
stats["embeddings"]["review_description_events_per_second"] = round(
embeddings_metrics.review_desc_dps.value, 2
)
if embeddings_metrics.object_desc_speed.value > 0.0:
stats["embeddings"]["object_description_speed"] = round(
embeddings_metrics.object_desc_speed.value * 1000, 2
)
stats["embeddings"]["object_description_events_per_second"] = round(
embeddings_metrics.object_desc_dps.value, 2
)
for key in embeddings_metrics.classification_speeds.keys():
stats["embeddings"][f"{key}_classification_speed"] = round(
embeddings_metrics.classification_speeds[key].value * 1000, 2
)
stats["embeddings"][f"{key}_classification_events_per_second"] = round(
embeddings_metrics.classification_cps[key].value, 2
)
stats["embeddings"] = embeddings_stats(
config, stats_tracking.get("embeddings_metrics")
)
hardware_stats.update_stats(stats)
@@ -0,0 +1,37 @@
"""Tests for credential scrubbing in the ffprobe API."""
from types import SimpleNamespace
from unittest.mock import patch
from fastapi.testclient import TestClient
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
class TestHttpFfprobe(BaseTestHttp):
def setUp(self):
super().setUp([])
self.app = self.create_app()
def client(self) -> TestClient:
return AuthTestClient(self.app)
def test_failed_probe_scrubs_credentials_from_stderr(self):
failed = SimpleNamespace(
returncode=1,
stdout=b"",
stderr=(
b"[tcp @ 0x1] Connection to tcp://10.0.0.1:554 failed\n"
b"rtsp://admin:hunter2@10.0.0.1:554/video: Connection refused\n"
),
)
with patch("frigate.api.camera.ffprobe_stream", return_value=failed):
with self.client() as client:
response = client.get("/ffprobe", params={"paths": "camera:front_door"})
self.assertEqual(response.status_code, 200)
lines = response.json()[0]["stderr"]
self.assertEqual(len(lines), 2)
self.assertNotIn("hunter2", " ".join(lines))
self.assertEqual(lines[1], "rtsp://*:*@10.0.0.1:554/video: Connection refused")
+28
View File
@@ -0,0 +1,28 @@
"""Tests that the tflite face model reports a runtime device."""
import unittest
from unittest.mock import MagicMock, patch
from frigate.detectors.detection_runners import loaded_devices, snapshot_loaded_devices
try:
from frigate.embeddings.onnx import face_embedding
except ImportError: # tflite runtime is not installed everywhere
face_embedding = None
@unittest.skipIf(face_embedding is None, "tflite runtime not available")
class TestFaceNetDevice(unittest.TestCase):
def setUp(self):
loaded_devices.clear()
def test_facenet_records_cpu(self):
with (
patch.object(face_embedding, "Interpreter", return_value=MagicMock()),
patch.object(face_embedding.os.path, "exists", return_value=True),
):
embedding = face_embedding.FaceNetEmbedding()
recorded = list(snapshot_loaded_devices().values())
self.assertEqual(recorded, [("facenet", "CPU")])
self.assertIsNotNone(embedding.runner)
+55
View File
@@ -100,3 +100,58 @@ class TestModelDownloadNotice(unittest.TestCase):
[n["scope"] for n in self._notice_calls(sibling.requestor)],
["paddleocr-onnx/det.onnx"],
)
class TestModelDownloadState(unittest.TestCase):
"""A failed download marks the model state as error, not stuck downloading."""
def setUp(self):
self.download_path = tempfile.mkdtemp()
downloader.last_download_error.clear()
def _state_calls(self, requestor: MagicMock) -> list[dict]:
return [
call.args[1]
for call in requestor.send_data.call_args_list
if call.args[0] == "update_model_state"
]
def _downloader(self, download_func) -> ModelDownloader:
with patch("frigate.util.downloader.InterProcessRequestor"):
return ModelDownloader(
"facedet", self.download_path, ["facedet.onnx"], download_func
)
def test_raising_download_marks_error(self):
def failing(path: str) -> None:
raise RuntimeError("boom")
model_downloader = self._downloader(failing)
with self.assertRaises(RuntimeError):
model_downloader._download_models()
states = self._state_calls(model_downloader.requestor)
self.assertEqual(states[-1]["state"], "error")
self.assertEqual(states[-1]["model"], "facedet-facedet.onnx")
def test_missing_file_marks_error(self):
def swallowing(path: str) -> None:
pass
model_downloader = self._downloader(swallowing)
model_downloader._download_models()
states = self._state_calls(model_downloader.requestor)
self.assertEqual([s["state"] for s in states], ["error"])
def test_success_marks_downloaded(self):
def succeeding(path: str) -> None:
with open(path, "w") as f:
f.write("model")
model_downloader = self._downloader(succeeding)
model_downloader._download_models()
states = self._state_calls(model_downloader.requestor)
self.assertEqual([s["state"] for s in states], ["downloaded"])
+131
View File
@@ -0,0 +1,131 @@
"""Tests for the device each model runner reports after loading."""
import threading
import unittest
from unittest.mock import MagicMock, patch
from frigate.detectors import detection_runners
from frigate.detectors.detection_runners import (
CudaGraphRunner,
ONNXModelRunner,
OpenVINOModelRunner,
RKNNModelRunner,
get_optimized_runner,
loaded_devices,
record_loaded_device,
snapshot_loaded_devices,
)
class TestRunnerDeviceName(unittest.TestCase):
def _onnx(self, providers: list[str]) -> ONNXModelRunner:
session = MagicMock()
session.get_providers.return_value = providers
return ONNXModelRunner(session, "arcface")
def test_onnx_provider_names(self):
self.assertEqual(
self._onnx(["CUDAExecutionProvider", "CPUExecutionProvider"]).device_name,
"CUDA",
)
self.assertEqual(
self._onnx(["TensorrtExecutionProvider"]).device_name, "TensorRT"
)
self.assertEqual(
self._onnx(["MIGraphXExecutionProvider"]).device_name, "MIGraphX"
)
self.assertEqual(
self._onnx(["OpenVINOExecutionProvider"]).device_name, "OpenVINO"
)
self.assertEqual(self._onnx(["CPUExecutionProvider"]).device_name, "CPU")
self.assertEqual(self._onnx(["ROCMExecutionProvider"]).device_name, "ROCM")
self.assertEqual(self._onnx([]).device_name, "CPU")
def test_cuda_graph_runner(self):
runner = CudaGraphRunner(MagicMock(), 0)
self.assertEqual(runner.device_name, "CUDA")
def test_openvino_reports_compiled_device(self):
runner = OpenVINOModelRunner.__new__(OpenVINOModelRunner)
runner.compiled_device = "GPU"
runner.compiled_model = MagicMock()
self.assertEqual(runner.device_name, "OpenVINO GPU")
def test_openvino_auto_resolves_execution_device(self):
runner = OpenVINOModelRunner.__new__(OpenVINOModelRunner)
runner.compiled_device = "AUTO"
runner.compiled_model = MagicMock()
runner.compiled_model.get_property.return_value = ["GPU.0"]
self.assertEqual(runner.device_name, "OpenVINO GPU.0")
runner.compiled_model.get_property.side_effect = RuntimeError("no prop")
self.assertEqual(runner.device_name, "OpenVINO AUTO")
def test_rknn(self):
runner = RKNNModelRunner.__new__(RKNNModelRunner)
self.assertEqual(runner.device_name, "RKNN")
class TestLoadRegistry(unittest.TestCase):
def setUp(self):
loaded_devices.clear()
def test_get_optimized_runner_records_device(self):
session = MagicMock()
session.get_providers.return_value = ["CPUExecutionProvider"]
with (
patch.object(detection_runners, "is_rknn_compatible", return_value=False),
patch.object(
detection_runners,
"get_ort_providers",
return_value=(["CPUExecutionProvider"], [{}]),
),
patch.object(
detection_runners, "is_openvino_gpu_npu_available", return_value=False
),
patch.object(
detection_runners.ort, "InferenceSession", return_value=session
),
patch.object(
detection_runners, "get_ort_session_options", return_value=None
),
):
runner = get_optimized_runner("/models/arcface.onnx", "GPU", "arcface")
self.assertIsInstance(runner, ONNXModelRunner)
self.assertEqual(loaded_devices["/models/arcface.onnx"], ("arcface", "CPU"))
class TestLoadedDeviceSnapshot(unittest.TestCase):
def setUp(self):
loaded_devices.clear()
def test_record_and_snapshot_copy(self):
record_loaded_device("/m/a.onnx", "arcface", "CUDA")
snapshot = snapshot_loaded_devices()
self.assertEqual(snapshot, {"/m/a.onnx": ("arcface", "CUDA")})
# a copy, so a load on another thread cannot disturb a fold in progress
record_loaded_device("/m/b.onnx", "jina_v1", "CPU")
self.assertNotIn("/m/b.onnx", snapshot)
self.assertIn("/m/b.onnx", loaded_devices)
def test_snapshot_survives_concurrent_inserts(self):
stop = threading.Event()
def writer() -> None:
i = 0
while not stop.is_set():
record_loaded_device(f"/m/{i}.onnx", "paddleocr", "CPU")
i += 1
thread = threading.Thread(target=writer, daemon=True)
thread.start()
try:
for _ in range(200):
for _entry in snapshot_loaded_devices().values():
pass
finally:
stop.set()
thread.join(timeout=5)
+108
View File
@@ -0,0 +1,108 @@
"""Tests for folding loaded model devices per enrichment and emitting them."""
import unittest
from types import SimpleNamespace
from frigate.config import FrigateConfig
from frigate.embeddings.types import ENRICHMENT_FOR_MODEL_TYPE, fold_runtime_devices
from frigate.stats.util import embeddings_stats
class TestFoldRuntimeDevices(unittest.TestCase):
def test_every_enrichment_model_type_is_mapped(self):
self.assertEqual(
set(ENRICHMENT_FOR_MODEL_TYPE),
{
"arcface",
"facenet",
"jina_v1",
"jina_v2",
"paddleocr",
"yolov9_license_plate",
},
)
def test_non_cpu_wins_within_an_enrichment(self):
# jina v1 pins its text model to the CPU by design
loaded = {
"/m/jina_v1_text.onnx": ("jina_v1", "CPU"),
"/m/jina_v1_vision.onnx": ("jina_v1", "OpenVINO GPU"),
}
self.assertEqual(
fold_runtime_devices(loaded), {"semantic_search": "OpenVINO GPU"}
)
def test_all_cpu_stays_cpu(self):
loaded = {
"/m/jina_v1_text.onnx": ("jina_v1", "CPU"),
"/m/jina_v1_vision.onnx": ("jina_v1", "CPU"),
}
self.assertEqual(fold_runtime_devices(loaded), {"semantic_search": "CPU"})
def test_detector_models_are_ignored(self):
loaded = {"/m/yolo.onnx": ("yolov9", "CUDA")}
self.assertEqual(fold_runtime_devices(loaded), {})
def test_lpr_and_face(self):
loaded = {
"/m/det.onnx": ("paddleocr", "CUDA"),
"/m/rec.onnx": ("paddleocr", "CUDA"),
"/m/arcface.onnx": ("arcface", "CPU"),
}
self.assertEqual(
fold_runtime_devices(loaded), {"lpr": "CUDA", "face_recognition": "CPU"}
)
def _metrics(devices: dict[str, str]) -> SimpleNamespace:
value = SimpleNamespace(value=0.0)
return SimpleNamespace(
image_embeddings_speed=value,
image_embeddings_eps=value,
text_embeddings_speed=value,
text_embeddings_eps=value,
face_rec_speed=value,
face_rec_fps=value,
alpr_speed=value,
alpr_pps=value,
yolov9_lpr_speed=value,
yolov9_lpr_pps=value,
review_desc_speed=value,
review_desc_dps=value,
object_desc_speed=value,
object_desc_dps=value,
classification_speeds={},
classification_cps={},
runtime_devices=devices,
)
class TestEmbeddingsStats(unittest.TestCase):
def setUp(self):
self.config = FrigateConfig(
**{
"mqtt": {"host": "mqtt"},
"face_recognition": {"enabled": True},
"cameras": {
"front_door": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
}
]
}
}
},
}
)
def test_devices_emitted_when_present(self):
stats = embeddings_stats(self.config, _metrics({"face_recognition": "CUDA"}))
self.assertEqual(stats["devices"], {"face_recognition": "CUDA"})
self.assertIn("face_recognition_speed", stats)
def test_devices_omitted_when_empty(self):
stats = embeddings_stats(self.config, _metrics({}))
self.assertNotIn("devices", stats)
+9 -7
View File
@@ -85,6 +85,12 @@ class ModelDownloader:
},
)
def _send_state(self, file_name: str, state: ModelStatusTypesEnum) -> None:
self.requestor.send_data(
UPDATE_MODEL_STATE,
{"model": f"{self.model_name}-{file_name}", "state": state},
)
def _download_models(self):
for file_name in self.file_names:
path = os.path.join(self.download_path, file_name)
@@ -98,6 +104,7 @@ class ModelDownloader:
self.download_func(path)
except Exception as e:
self._report_failure(file_name, _first_line(e))
self._send_state(file_name, ModelStatusTypesEnum.error)
raise
if not os.path.exists(path):
@@ -105,16 +112,11 @@ class ModelDownloader:
file_name,
last_download_error.pop(path, "download failed"),
)
self._send_state(file_name, ModelStatusTypesEnum.error)
continue
self._resolve_failure(file_name)
self.requestor.send_data(
UPDATE_MODEL_STATE,
{
"model": f"{self.model_name}-{file_name}",
"state": ModelStatusTypesEnum.downloaded,
},
)
self._send_state(file_name, ModelStatusTypesEnum.downloaded)
if self.complete_func:
self.complete_func()