mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-28 01:58:59 +03:00
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:
committed by
Nicolas Mowen
parent
6d33b31bc6
commit
70ce193e09
@@ -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 = {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user