adapt analytics to free-form model scenes, split notice counts, and peewee 4

This commit is contained in:
Josh Hawkins
2026-10-29 16:17:20 -05:00
parent 7f65087609
commit d3bc434e2d
12 changed files with 111 additions and 60 deletions
+35 -25
View File
@@ -373,6 +373,7 @@
"intel-qsv", "intel-qsv",
"jetson", "jetson",
"rpi", "rpi",
"apple-silicon",
"other" "other"
], ],
"title": "DecodeFamily", "title": "DecodeFamily",
@@ -381,6 +382,12 @@
"DetectionModel": { "DetectionModel": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"default": {
"description": "Whether the model serves cameras that set no scene",
"title": "Default",
"type": "boolean",
"x-public": true
},
"detector": { "detector": {
"$ref": "#/$defs/DetectorTypeEnum", "$ref": "#/$defs/DetectorTypeEnum",
"description": "Detector type the model runs on", "description": "Detector type the model runs on",
@@ -396,7 +403,7 @@
"inference_ms": { "inference_ms": {
"anyOf": [ "anyOf": [
{ {
"minimum": 0.0, "minimum": 0,
"type": "number" "type": "number"
}, },
{ {
@@ -426,6 +433,7 @@
} }
}, },
"required": [ "required": [
"default",
"detector", "detector",
"devices", "devices",
"model_type", "model_type",
@@ -441,26 +449,23 @@
"properties": { "properties": {
"detection_fps": { "detection_fps": {
"description": "Detections per second across cameras", "description": "Detections per second across cameras",
"minimum": 0.0, "minimum": 0,
"title": "Detection Fps", "title": "Detection Fps",
"type": "number", "type": "number",
"x-public": true "x-public": true
}, },
"models": { "models": {
"additionalProperties": { "description": "Detection models",
"items": {
"$ref": "#/$defs/DetectionModel" "$ref": "#/$defs/DetectionModel"
}, },
"description": "Detection models keyed by scene",
"propertyNames": {
"$ref": "#/$defs/SceneEnum"
},
"title": "Models", "title": "Models",
"type": "object", "type": "array",
"x-public": true "x-public": true
}, },
"skipped_fps": { "skipped_fps": {
"description": "Frames per second skipped across cameras", "description": "Frames per second skipped across cameras",
"minimum": 0.0, "minimum": 0,
"title": "Skipped Fps", "title": "Skipped Fps",
"type": "number", "type": "number",
"x-public": true "x-public": true
@@ -476,6 +481,7 @@
}, },
"DetectorTypeEnum": { "DetectorTypeEnum": {
"enum": [ "enum": [
"axelera",
"axengine", "axengine",
"cpu", "cpu",
"deepx", "deepx",
@@ -772,6 +778,7 @@
"nvidia", "nvidia",
"rockchip", "rockchip",
"rpi", "rpi",
"apple-silicon",
"other" "other"
], ],
"title": "GpuVendor", "title": "GpuVendor",
@@ -785,12 +792,14 @@
"openvino:NPU", "openvino:NPU",
"onnx:amd", "onnx:amd",
"onnx:nvidia", "onnx:nvidia",
"onnx:lighter",
"tensorrt", "tensorrt",
"hailo", "hailo",
"memryx", "memryx",
"deepx", "deepx",
"rknn", "rknn",
"axengine", "axengine",
"axelera",
"synaptics", "synaptics",
"cpu", "cpu",
"other" "other"
@@ -945,6 +954,8 @@
"enum": [ "enum": [
"rpi-64-h264", "rpi-64-h264",
"rpi-64-h265", "rpi-64-h265",
"apple-silicon-h264",
"apple-silicon-h265",
"vaapi", "vaapi",
"intel-qsv-h264", "intel-qsv-h264",
"intel-qsv-h265", "intel-qsv-h265",
@@ -1091,10 +1102,17 @@
"NoticeCounts": { "NoticeCounts": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"dismissals": { "acknowledgements": {
"description": "Dismissals since the last accepted report", "description": "Acknowledgements since the last accepted report",
"minimum": 0, "minimum": 0,
"title": "Dismissals", "title": "Acknowledgements",
"type": "integer",
"x-public": true
},
"mutes": {
"description": "Mutes since the last accepted report",
"minimum": 0,
"title": "Mutes",
"type": "integer", "type": "integer",
"x-public": true "x-public": true
}, },
@@ -1108,7 +1126,8 @@
}, },
"required": [ "required": [
"occurrences", "occurrences",
"dismissals" "acknowledgements",
"mutes"
], ],
"title": "NoticeCounts", "title": "NoticeCounts",
"type": "object" "type": "object"
@@ -1118,7 +1137,10 @@
"detector_stuck", "detector_stuck",
"model_download_failed", "model_download_failed",
"skipped_detections", "skipped_detections",
"ffmpeg_high_cpu",
"detect_high_cpu",
"shm_too_low", "shm_too_low",
"object_processing_behind",
"failed_login" "failed_login"
], ],
"title": "NoticeKindKey", "title": "NoticeKindKey",
@@ -1195,18 +1217,6 @@
"title": "RetainDays", "title": "RetainDays",
"type": "object" "type": "object"
}, },
"SceneEnum": {
"description": "The camera environment a detection model is intended for.",
"enum": [
"all",
"indoor",
"outdoor",
"indoor_thermal",
"outdoor_thermal"
],
"title": "SceneEnum",
"type": "string"
},
"SemanticSearchModel": { "SemanticSearchModel": {
"enum": [ "enum": [
"jinav1", "jinav1",
+6 -3
View File
@@ -7,7 +7,7 @@ from frigate.analytics.context import ReportContext
from frigate.analytics.schema import DetectionModel, DetectionSection, ModelSource from frigate.analytics.schema import DetectionModel, DetectionSection, ModelSource
from frigate.config.config import DEFAULT_MODEL from frigate.config.config import DEFAULT_MODEL
from frigate.const import MODEL_CACHE_DIR from frigate.const import MODEL_CACHE_DIR
from frigate.detectors.detector_config import SceneEnum from frigate.detectors.detector_config import DEFAULT_SCENE
from frigate.detectors.detector_types import DetectorTypeEnum from frigate.detectors.detector_types import DetectorTypeEnum
from frigate.detectors.device import runner_names from frigate.detectors.device import runner_names
@@ -38,7 +38,7 @@ def collect(ctx: ReportContext) -> DetectionSection:
# FrigateApp.start_detectors names the processes in this same order # FrigateApp.start_detectors names the processes in this same order
names = iter(runner_names([spec for _, specs in model_specs for spec in specs])) names = iter(runner_names([spec for _, specs in model_specs for spec in specs]))
models: dict[SceneEnum, DetectionModel] = {} models: list[DetectionModel] = []
for model, specs in model_specs: for model, specs in model_specs:
speeds: list[float] = [] speeds: list[float] = []
@@ -49,7 +49,9 @@ def collect(ctx: ReportContext) -> DetectionSection:
if isinstance(speed, int | float) and speed > 0: if isinstance(speed, int | float) and speed > 0:
speeds.append(float(speed)) speeds.append(float(speed))
models[model.scene] = DetectionModel( models.append(
DetectionModel(
default=model.scene == DEFAULT_SCENE,
detector=DetectorTypeEnum(specs[0].detector), detector=DetectorTypeEnum(specs[0].detector),
devices=len(specs), devices=len(specs),
model_type=model.model_type, model_type=model.model_type,
@@ -57,6 +59,7 @@ def collect(ctx: ReportContext) -> DetectionSection:
source=model_source(model.path), source=model_source(model.path),
inference_ms=rate(sum(speeds) / len(speeds)) if speeds else None, inference_ms=rate(sum(speeds) / len(speeds)) if speeds else None,
) )
)
return DetectionSection( return DetectionSection(
models=models, models=models,
+6 -3
View File
@@ -32,11 +32,14 @@ def notice_deltas(notice_stats: list[dict[str, Any]]) -> dict[Any, NoticeCounts]
continue continue
occurrences = max(row["occurrences"] - row["reported_occurrences"], 0) occurrences = max(row["occurrences"] - row["reported_occurrences"], 0)
dismissals = max(row["dismissals"] - row["reported_dismissals"], 0) acknowledgements = max(
row["acknowledgements"] - row["reported_acknowledgements"], 0
)
mutes = max(row["mutes"] - row["reported_mutes"], 0)
if occurrences or dismissals: if occurrences or acknowledgements or mutes:
deltas[NoticeKindKey(row["kind"])] = NoticeCounts( deltas[NoticeKindKey(row["kind"])] = NoticeCounts(
occurrences=occurrences, dismissals=dismissals occurrences=occurrences, acknowledgements=acknowledgements, mutes=mutes
) )
return deltas return deltas
+9 -3
View File
@@ -13,7 +13,7 @@ from frigate.config.camera.birdseye import BirdseyeModeEnum
from frigate.config.camera.camera import CameraTypeEnum from frigate.config.camera.camera import CameraTypeEnum
from frigate.config.camera.genai import GenAIProviderEnum, GenAIRoleEnum from frigate.config.camera.genai import GenAIProviderEnum, GenAIRoleEnum
from frigate.config.classification import ModelSizeEnum from frigate.config.classification import ModelSizeEnum
from frigate.detectors.detector_config import ModelTypeEnum, SceneEnum from frigate.detectors.detector_config import ModelTypeEnum
from frigate.detectors.detector_types import DetectorTypeEnum from frigate.detectors.detector_types import DetectorTypeEnum
from frigate.ffmpeg_presets import PRESETS_HW_ACCEL_DECODE, PRESETS_INPUT from frigate.ffmpeg_presets import PRESETS_HW_ACCEL_DECODE, PRESETS_INPUT
from frigate.notices.types import NOTICE_KINDS from frigate.notices.types import NOTICE_KINDS
@@ -53,6 +53,7 @@ class GpuVendor(StrEnum):
nvidia = "nvidia" nvidia = "nvidia"
rockchip = "rockchip" rockchip = "rockchip"
rpi = "rpi" rpi = "rpi"
apple_silicon = "apple-silicon"
other = "other" other = "other"
@@ -63,6 +64,7 @@ class DecodeFamily(StrEnum):
intel_qsv = "intel-qsv" intel_qsv = "intel-qsv"
jetson = "jetson" jetson = "jetson"
rpi = "rpi" rpi = "rpi"
apple_silicon = "apple-silicon"
other = "other" other = "other"
@@ -73,12 +75,14 @@ class HardwareKey(StrEnum):
openvino_npu = "openvino:NPU" openvino_npu = "openvino:NPU"
onnx_amd = "onnx:amd" onnx_amd = "onnx:amd"
onnx_nvidia = "onnx:nvidia" onnx_nvidia = "onnx:nvidia"
onnx_lighter = "onnx:lighter"
tensorrt = "tensorrt" tensorrt = "tensorrt"
hailo = "hailo" hailo = "hailo"
memryx = "memryx" memryx = "memryx"
deepx = "deepx" deepx = "deepx"
rknn = "rknn" rknn = "rknn"
axengine = "axengine" axengine = "axengine"
axelera = "axelera"
synaptics = "synaptics" synaptics = "synaptics"
cpu = "cpu" cpu = "cpu"
other = "other" other = "other"
@@ -228,6 +232,7 @@ class HardwareSection(AnalyticsModel):
class DetectionModel(AnalyticsModel): class DetectionModel(AnalyticsModel):
default: bool = metric("Whether the model serves cameras that set no scene")
detector: DetectorTypeEnum = metric("Detector type the model runs on") detector: DetectorTypeEnum = metric("Detector type the model runs on")
devices: int = count("Devices the model runs on") devices: int = count("Devices the model runs on")
model_type: ModelTypeEnum = metric("Model architecture") model_type: ModelTypeEnum = metric("Model architecture")
@@ -240,7 +245,7 @@ class DetectionModel(AnalyticsModel):
class DetectionSection(AnalyticsModel): class DetectionSection(AnalyticsModel):
models: dict[SceneEnum, DetectionModel] = metric("Detection models keyed by scene") models: list[DetectionModel] = metric("Detection models")
detection_fps: float = metric("Detections per second across cameras", ge=0) detection_fps: float = metric("Detections per second across cameras", ge=0)
skipped_fps: float = metric("Frames per second skipped across cameras", ge=0) skipped_fps: float = metric("Frames per second skipped across cameras", ge=0)
@@ -369,7 +374,8 @@ class FeaturesSection(AnalyticsModel):
class NoticeCounts(AnalyticsModel): class NoticeCounts(AnalyticsModel):
occurrences: int = count("Occurrences since the last accepted report") occurrences: int = count("Occurrences since the last accepted report")
dismissals: int = count("Dismissals since the last accepted report") acknowledgements: int = count("Acknowledgements since the last accepted report")
mutes: int = count("Mutes since the last accepted report")
class HealthSection(AnalyticsModel): class HealthSection(AnalyticsModel):
+1 -1
View File
@@ -209,7 +209,7 @@ class NoticeStats(Model):
mutes = IntegerField(default=0) mutes = IntegerField(default=0)
first_seen = DateTimeField() first_seen = DateTimeField()
last_seen = DateTimeField() last_seen = DateTimeField()
# watermarks for a future analytics reporter; unused until then # counts the last accepted analytics report covered
reported_occurrences = IntegerField(default=0) reported_occurrences = IntegerField(default=0)
reported_acknowledgements = IntegerField(default=0) reported_acknowledgements = IntegerField(default=0)
reported_mutes = IntegerField(default=0) reported_mutes = IntegerField(default=0)
+2 -1
View File
@@ -395,7 +395,8 @@ class NoticeRegistry:
for row in snapshot: for row in snapshot:
NoticeStats.update( NoticeStats.update(
reported_occurrences=row["occurrences"], reported_occurrences=row["occurrences"],
reported_dismissals=row["dismissals"], reported_acknowledgements=row["acknowledgements"],
reported_mutes=row["mutes"],
).where(NoticeStats.kind == row["kind"]).execute() ).where(NoticeStats.kind == row["kind"]).execute()
def _write_repeats( def _write_repeats(
@@ -7,7 +7,6 @@ from unittest.mock import patch
from frigate.analytics.collectors import detection from frigate.analytics.collectors import detection
from frigate.analytics.schema import ModelSource from frigate.analytics.schema import ModelSource
from frigate.detectors.detector_config import SceneEnum
from frigate.test.analytics_helpers import make_config, make_context from frigate.test.analytics_helpers import make_config, make_context
@@ -25,7 +24,8 @@ class TestDetectionCollector(unittest.TestCase):
section = detection.collect(make_context(config, stats)) section = detection.collect(make_context(config, stats))
model = section.models[SceneEnum.all] [model] = section.models
self.assertTrue(model.default)
self.assertEqual(model.detector, "cpu") self.assertEqual(model.detector, "cpu")
self.assertEqual(model.devices, 2) self.assertEqual(model.devices, 2)
self.assertEqual(model.model_type, "ssd") self.assertEqual(model.model_type, "ssd")
@@ -35,10 +35,29 @@ class TestDetectionCollector(unittest.TestCase):
self.assertEqual(section.detection_fps, 12.35) self.assertEqual(section.detection_fps, 12.35)
self.assertEqual(section.skipped_fps, 0.0) self.assertEqual(section.skipped_fps, 0.0)
def test_named_scene_reports_no_name(self):
config = make_config(
{
"models": [
{"devices": ["cpu"]},
{
"scene": "backyard_thermal",
"devices": ["cpu"],
"path": "/thermal.tflite",
},
]
}
)
section = detection.collect(make_context(config))
self.assertEqual([m.default for m in section.models], [True, False])
self.assertNotIn("backyard_thermal", section.model_dump_json())
def test_inference_is_null_before_the_first_stats(self): def test_inference_is_null_before_the_first_stats(self):
section = detection.collect(make_context()) section = detection.collect(make_context())
self.assertIsNone(section.models[SceneEnum.all].inference_ms) self.assertIsNone(section.models[0].inference_ms)
def test_model_source(self): def test_model_source(self):
with tempfile.TemporaryDirectory() as cache: with tempfile.TemporaryDirectory() as cache:
@@ -4,8 +4,8 @@ import logging
import os import os
import unittest import unittest
from peewee import SqliteDatabase
from peewee_migrate import Router from peewee_migrate import Router
from playhouse.sqlite_ext import SqliteExtDatabase
from playhouse.sqliteq import SqliteQueueDatabase from playhouse.sqliteq import SqliteQueueDatabase
from frigate.analytics.collectors import features from frigate.analytics.collectors import features
@@ -49,7 +49,7 @@ CONFIG = {
class TestFeaturesCollector(unittest.TestCase): class TestFeaturesCollector(unittest.TestCase):
def setUp(self): def setUp(self):
migrate_db = SqliteExtDatabase(TEST_DB) migrate_db = SqliteDatabase(TEST_DB)
del logging.getLogger("peewee_migrate").handlers[:] del logging.getLogger("peewee_migrate").handlers[:]
Router(migrate_db).run() Router(migrate_db).run()
migrate_db.close() migrate_db.close()
@@ -21,23 +21,29 @@ class TestHealthCollector(unittest.TestCase):
{ {
"kind": "detector_stuck", "kind": "detector_stuck",
"occurrences": 5, "occurrences": 5,
"dismissals": 1, "acknowledgements": 1,
"mutes": 0,
"reported_occurrences": 3, "reported_occurrences": 3,
"reported_dismissals": 1, "reported_acknowledgements": 1,
"reported_mutes": 0,
}, },
{ {
"kind": "shm_too_low", "kind": "shm_too_low",
"occurrences": 2, "occurrences": 2,
"dismissals": 0, "acknowledgements": 0,
"mutes": 0,
"reported_occurrences": 2, "reported_occurrences": 2,
"reported_dismissals": 0, "reported_acknowledgements": 0,
"reported_mutes": 0,
}, },
{ {
"kind": "update_available", "kind": "update_available",
"occurrences": 4, "occurrences": 4,
"dismissals": 0, "acknowledgements": 0,
"mutes": 0,
"reported_occurrences": 0, "reported_occurrences": 0,
"reported_dismissals": 0, "reported_acknowledgements": 0,
"reported_mutes": 0,
}, },
] ]
@@ -49,10 +55,10 @@ class TestHealthCollector(unittest.TestCase):
self.assertTrue(section.retention_unmet) self.assertTrue(section.retention_unmet)
self.assertEqual( self.assertEqual(
{ {
kind: (c.occurrences, c.dismissals) kind: (c.occurrences, c.acknowledgements, c.mutes)
for kind, c in section.notices.items() for kind, c in section.notices.items()
}, },
{"detector_stuck": (2, 0)}, {"detector_stuck": (2, 0, 0)},
) )
def test_missing_stats_give_zeros(self): def test_missing_stats_give_zeros(self):
+2 -2
View File
@@ -6,8 +6,8 @@ import os
import unittest import unittest
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from peewee import SqliteDatabase
from peewee_migrate import Router from peewee_migrate import Router
from playhouse.sqlite_ext import SqliteExtDatabase
from playhouse.sqliteq import SqliteQueueDatabase from playhouse.sqliteq import SqliteQueueDatabase
from frigate.analytics import report from frigate.analytics import report
@@ -50,7 +50,7 @@ class TestBuildReport(unittest.TestCase):
class TestRealCollectors(unittest.TestCase): class TestRealCollectors(unittest.TestCase):
def setUp(self): def setUp(self):
migrate_db = SqliteExtDatabase(TEST_DB) migrate_db = SqliteDatabase(TEST_DB)
del logging.getLogger("peewee_migrate").handlers[:] del logging.getLogger("peewee_migrate").handlers[:]
Router(migrate_db).run() Router(migrate_db).run()
migrate_db.close() migrate_db.close()
+4 -2
View File
@@ -24,9 +24,11 @@ SNAPSHOT = [
{ {
"kind": "detector_stuck", "kind": "detector_stuck",
"occurrences": 3, "occurrences": 3,
"dismissals": 0, "acknowledgements": 0,
"mutes": 0,
"reported_occurrences": 1, "reported_occurrences": 1,
"reported_dismissals": 0, "reported_acknowledgements": 0,
"reported_mutes": 0,
} }
] ]
+2 -1
View File
@@ -372,7 +372,8 @@ class TestMarkReported(RegistryTestCase):
row = self.registry.stats()[0] row = self.registry.stats()[0]
self.assertEqual(row["occurrences"], 2) self.assertEqual(row["occurrences"], 2)
self.assertEqual(row["reported_occurrences"], 1) self.assertEqual(row["reported_occurrences"], 1)
self.assertEqual(row["reported_dismissals"], 0) self.assertEqual(row["reported_acknowledgements"], 0)
self.assertEqual(row["reported_mutes"], 0)
class TestApply(RegistryTestCase): class TestApply(RegistryTestCase):