Compare commits

..
Author SHA1 Message Date
Nicolas Mowen 40ea80d43b Handle form validation 2026-09-29 16:12:46 -06:00
Nicolas Mowen 267aa60001 Validate model paths in the UI 2026-09-29 16:06:42 -06:00
Nicolas Mowen f4d2b3c045 Cleanup 2026-09-29 15:56:42 -06:00
Nicolas Mowen 15bed3fb31 Refactor model scene definitions 2026-09-29 15:34:43 -06:00
30 changed files with 862 additions and 227 deletions
@@ -152,9 +152,10 @@ auth:
models: models:
# Optional: the camera environment this model is for (default: shown below) # Optional: the camera environment this model is for (default: shown below)
# Cameras select a model by setting detect -> scene to a matching value, and # Cameras select a model by setting detect -> scene to a matching value, and
# a model with a scene of all is used by any camera that does not set one. # the model with a scene of default is used by any camera that does not set one.
# Valid values are all, indoor, outdoor, indoor_thermal, outdoor_thermal # Any name made up of letters, numbers, _ and - is valid, such as thermal.
- scene: all # Models that use the same model file are combined into one model.
- scene: default
# Required: hardware this model runs on, as <detector> or <detector>:<device> # Required: hardware this model runs on, as <detector> or <detector>:<device>
# See https://docs.frigate.video/configuration/object_detectors for the # See https://docs.frigate.video/configuration/object_detectors for the
# detectors available and the devices each one accepts. All of a model's # detectors available and the devices each one accepts. All of a model's
@@ -316,9 +317,9 @@ detect:
# Optional: height of the frame for the input with the detect role (default: use native stream resolution) # Optional: height of the frame for the input with the detect role (default: use native stream resolution)
height: 720 height: 720
# Optional: the environment this camera looks at, which picks the model it runs on # Optional: the environment this camera looks at, which picks the model it runs on
# (default: the model with a scene of all) # (default: the model with a scene of default)
# Valid values are all, indoor, outdoor, indoor_thermal, outdoor_thermal # Must match the scene of a configured model
scene: outdoor scene: thermal
# Optional: desired fps for your camera for the input with the detect role (default: shown below) # Optional: desired fps for your camera for the input with the detect role (default: shown below)
# NOTE: Recommended value of 5. Ideally, try and reduce your FPS on the camera. # NOTE: Recommended value of 5. Ideally, try and reduce your FPS on the camera.
fps: 5 fps: 5
+14 -10
View File
@@ -103,32 +103,36 @@ Coral EdgeTPU and MemryX accelerators can only be opened by one process, so thos
### Running more than one model ### Running more than one model
Cameras can be split across models by scene, which is useful when indoor and outdoor cameras benefit from differently trained models. Each model declares the `scene` it is for, and each camera picks one with `detect -> scene`: Cameras can be split across models by scene, which is useful when some cameras benefit from a differently trained model, such as thermal cameras. Each model declares the `scene` it is for, and each camera picks one with `detect -> scene`:
```yaml ```yaml
models: models:
- scene: outdoor - scene: default
path: plus://your-outdoor-model path: plus://your-model
devices: devices:
- edgetpu:pci:0 - edgetpu:pci:0
- scene: indoor - scene: thermal
path: /config/model_cache/indoor.onnx path: /config/model_cache/thermal.onnx
model_type: yolo-generic model_type: yolo-generic
devices: devices:
- openvino:GPU - openvino:GPU
cameras: cameras:
driveway: driveway:
detect:
scene: outdoor
... ...
hallway: backyard_thermal:
detect: detect:
scene: indoor scene: thermal
... ...
``` ```
Available scenes are `all`, `indoor`, `outdoor`, `indoor_thermal`, and `outdoor_thermal`. A model with a scene of `all` is used by every camera that does not set one, and `all` is the default when a model does not declare a scene. Changing a camera's scene requires a restart. A scene is any name made up of letters, numbers, `_`, and `-`. The model with a scene of `default` is used by every camera that does not set one (or sets a scene that no model is configured for), and `default` is used when a model does not declare a scene. Changing a camera's scene requires a restart.
:::warning
Scenes are for running **different** models. Do not configure the same model under several scenes to dedicate a detector to specific cameras: every detector of a model already serves every camera using it, and splitting them only leaves some detectors idle while others fall behind. Frigate detects models that use the same model file, even under a different path or file name, combines them into one model with all of their hardware, and logs a warning.
:::
### Choosing a model size ### Choosing a model size
+2 -3
View File
@@ -49,7 +49,6 @@ from frigate.debug_replay import (
DebugReplayManager, DebugReplayManager,
cleanup_replay_cameras, cleanup_replay_cameras,
) )
from frigate.detectors.detector_config import SceneEnum
from frigate.detectors.detector_types import api_types from frigate.detectors.detector_types import api_types
from frigate.detectors.device import build_detector_config, runner_names from frigate.detectors.device import build_detector_config, runner_names
from frigate.embeddings import EmbeddingProcess, EmbeddingsContext from frigate.embeddings import EmbeddingProcess, EmbeddingsContext
@@ -108,7 +107,7 @@ class FrigateApp:
self.metrics_manager = manager self.metrics_manager = manager
self.audio_process: mp.Process | None = None self.audio_process: mp.Process | None = None
self.stop_event = stop_event self.stop_event = stop_event
self.detection_queues: dict[SceneEnum, Queue] = { self.detection_queues: dict[str, Queue] = {
model.scene: mp.Queue() for model in config.models model.scene: mp.Queue() for model in config.models
} }
self.detectors: dict[str, ObjectDetectProcess] = {} self.detectors: dict[str, ObjectDetectProcess] = {}
@@ -395,7 +394,7 @@ class FrigateApp:
logger.error("Unable to prepare the %s runtime: %s", detector_type, err) logger.error("Unable to prepare the %s runtime: %s", detector_type, err)
def start_detectors(self) -> None: def start_detectors(self) -> None:
model_cameras: dict[SceneEnum, list[str]] = { model_cameras: dict[str, list[str]] = {
model.scene: [] for model in self.config.models model.scene: [] for model in self.config.models
} }
+1 -2
View File
@@ -15,7 +15,6 @@ from frigate.config.camera.updater import (
CameraConfigUpdateSubscriber, CameraConfigUpdateSubscriber,
) )
from frigate.const import REPLAY_CAMERA_PREFIX from frigate.const import REPLAY_CAMERA_PREFIX
from frigate.detectors.detector_config import SceneEnum
from frigate.models import Regions from frigate.models import Regions
from frigate.object_detection.util import detection_frame_size from frigate.object_detection.util import detection_frame_size
from frigate.util.builtin import empty_and_close_queue from frigate.util.builtin import empty_and_close_queue
@@ -31,7 +30,7 @@ class CameraMaintainer(threading.Thread):
def __init__( def __init__(
self, self,
config: FrigateConfig, config: FrigateConfig,
detection_queues: dict[SceneEnum, Queue], detection_queues: dict[str, Queue],
detected_frames_queue: Queue, detected_frames_queue: Queue,
camera_metrics: DictProxy, camera_metrics: DictProxy,
ptz_metrics: dict[str, PTZMetrics], ptz_metrics: dict[str, PTZMetrics],
+5 -4
View File
@@ -1,6 +1,6 @@
from pydantic import Field, model_validator from pydantic import Field, model_validator
from frigate.detectors.detector_config import SceneEnum from frigate.detectors.detector_config import DEFAULT_SCENE, SCENE_PATTERN
from ..base import FrigateBaseModel from ..base import FrigateBaseModel
@@ -62,10 +62,11 @@ class DetectConfig(FrigateBaseModel):
title="Detect width", title="Detect width",
description="Width (pixels) of frames used for the detect stream; leave empty to use the native stream resolution.", description="Width (pixels) of frames used for the detect stream; leave empty to use the native stream resolution.",
) )
scene: SceneEnum = Field( scene: str = Field(
default=SceneEnum.all, default=DEFAULT_SCENE,
pattern=SCENE_PATTERN,
title="Detect scene", title="Detect scene",
description="The environment this camera looks at, used to pick which of the configured models runs on it. Cameras left on 'all' run the model configured with a scene of 'all'.", description="The environment this camera looks at, used to pick which of the configured models runs on it. Cameras left on 'default' run the model configured with a scene of 'default'.",
) )
fps: int = Field( fps: int = Field(
default=5, default=5,
+103 -22
View File
@@ -19,7 +19,7 @@ from ruamel.yaml import YAML
from frigate.const import REGEX_JSON from frigate.const import REGEX_JSON
from frigate.detectors import ModelConfig from frigate.detectors import ModelConfig
from frigate.detectors.detector_config import SceneEnum from frigate.detectors.detector_config import DEFAULT_SCENE
from frigate.detectors.device import DeviceParseError, DeviceSpec, parse_device from frigate.detectors.device import DeviceParseError, DeviceSpec, parse_device
from frigate.plus import PlusApi from frigate.plus import PlusApi
from frigate.util.builtin import ( from frigate.util.builtin import (
@@ -35,6 +35,7 @@ from frigate.util.config import (
migrate_frigate_config, migrate_frigate_config,
) )
from frigate.util.image import create_mask from frigate.util.image import create_mask
from frigate.util.runtime_deps import sha256_of
from frigate.util.services import auto_detect_hwaccel from frigate.util.services import auto_detect_hwaccel
from .auth import AuthConfig from .auth import AuthConfig
@@ -536,7 +537,7 @@ class FrigateConfig(FrigateBaseModel):
models: list[ModelConfig] = Field( models: list[ModelConfig] = Field(
default_factory=_default_models, default_factory=_default_models,
title="Detection models", title="Detection models",
description="Object detection models and the hardware each one runs on. Cameras pick a model by matching their detect.scene against a model's scene.", description="Object detection models and the hardware each one runs on. Cameras pick a model by matching their detect.scene against a model's scene, falling back to the 'default' model.",
) )
# GenAI config (named provider configs: name -> GenAIConfig) # GenAI config (named provider configs: name -> GenAIConfig)
@@ -651,7 +652,9 @@ class FrigateConfig(FrigateBaseModel):
) )
_plus_api: PlusApi _plus_api: PlusApi
_model_devices: dict[SceneEnum, list[DeviceSpec]] _model_devices: dict[str, list[DeviceSpec]]
# scene -> model, including the scenes of duplicate models folded into another
_scene_models: dict[str, ModelConfig]
_camera_models: dict[str, ModelConfig] _camera_models: dict[str, ModelConfig]
_all_attributes: list[str] _all_attributes: list[str]
_all_attribute_logos: list[str] _all_attribute_logos: list[str]
@@ -686,7 +689,7 @@ class FrigateConfig(FrigateBaseModel):
def primary_model(self) -> ModelConfig: def primary_model(self) -> ModelConfig:
"""The model used when no specific camera is in play.""" """The model used when no specific camera is in play."""
for model in self.models: for model in self.models:
if model.scene == SceneEnum.all: if model.scene == DEFAULT_SCENE:
return model return model
return self.models[0] return self.models[0]
@@ -708,7 +711,7 @@ class FrigateConfig(FrigateBaseModel):
if model is None: if model is None:
camera = self.cameras.get(camera_name) camera = self.cameras.get(camera_name)
scene = camera.detect.scene if camera is not None else SceneEnum.all scene = camera.detect.scene if camera is not None else DEFAULT_SCENE
model = self._resolve_camera_model(camera_name, scene) model = self._resolve_camera_model(camera_name, scene)
self._camera_models[camera_name] = model self._camera_models[camera_name] = model
@@ -768,14 +771,14 @@ class FrigateConfig(FrigateBaseModel):
if not self.models: if not self.models:
raise ValueError("At least one model must be configured under models") raise ValueError("At least one model must be configured under models")
model_devices: dict[SceneEnum, list[DeviceSpec]] = {} model_devices: dict[str, list[DeviceSpec]] = {}
# device string -> the scene of the model that already claimed it # device string -> the scene of the model that already claimed it
claimed_devices: dict[str, SceneEnum] = {} claimed_devices: dict[str, str] = {}
for index, model in enumerate(self.models): for index, model in enumerate(self.models):
scene = model.scene.value scene = model.scene
if model.scene in model_devices: if scene in model_devices:
raise ValueError( raise ValueError(
f"Multiple models are configured with a scene of '{scene}'. Each model must use a different scene." f"Multiple models are configured with a scene of '{scene}'. Each model must use a different scene."
) )
@@ -804,17 +807,20 @@ class FrigateConfig(FrigateBaseModel):
other = claimed_devices[device.raw] other = claimed_devices[device.raw]
where = ( where = (
f"twice by model '{scene}'" f"twice by model '{scene}'"
if other == model.scene if other == scene
else f"by both the '{other.value}' and '{scene}' models" else f"by both the '{other}' and '{scene}' models"
) )
raise ValueError( raise ValueError(
f"Device '{device.raw}' is used {where}, but it can only run one detection process." f"Device '{device.raw}' is used {where}, but it can only run one detection process."
) )
claimed_devices[device.raw] = model.scene claimed_devices[device.raw] = scene
self.models[index] = self._load_model(model, devices[0].detector) self.models[index] = self._load_model(model, devices[0].detector)
model_devices[model.scene] = devices model_devices[scene] = devices
self._scene_models = {model.scene: model for model in self.models}
self._consolidate_duplicate_models(model_devices)
attributes: set[str] = set() attributes: set[str] = set()
attribute_logos: set[str] = set() attribute_logos: set[str] = set()
@@ -838,36 +844,107 @@ class FrigateConfig(FrigateBaseModel):
} }
self._all_labels = labels self._all_labels = labels
def _resolve_camera_model(self, name: str, scene: SceneEnum) -> ModelConfig: def _consolidate_duplicate_models(
self, model_devices: dict[str, list[DeviceSpec]]
) -> None:
"""Fold models that load the same model file into a single model.
Separate scenes for one model only split the same work across separate
detection queues, so each device serves fewer cameras and is slower
overall than one shared model. The duplicate's devices are moved to the
model it duplicates and its scene resolves to that model.
Args:
model_devices: Scene to parsed devices, updated in place
"""
kept: list[ModelConfig] = []
hashes: dict[str, str | None] = {}
def file_hash(path: str) -> str | None:
if path not in hashes:
hashes[path] = sha256_of(path) if os.path.isfile(path) else None
return hashes[path]
def same_model(a: ModelConfig, b: ModelConfig) -> bool:
# a model's devices all share a detector, so folding across
# detectors would produce an invalid model
if model_devices[a.scene][0].detector != model_devices[b.scene][0].detector:
return False
if not a.path or not b.path:
return False
if os.path.realpath(a.path) == os.path.realpath(b.path):
return True
a_hash = file_hash(a.path)
return a_hash is not None and a_hash == file_hash(b.path)
for model in self.models:
original = next((other for other in kept if same_model(other, model)), None)
if original is None:
kept.append(model)
continue
# keep the default model so cameras without a scene still find it
if model.scene == DEFAULT_SCENE:
kept[kept.index(original)] = model
original, model = model, original
logger.warning(
"Models '%s' and '%s' use the same model file, so they have been combined into the '%s' model. Defining one model under several scenes to assign detectors to specific cameras is slower and less efficient than letting every detector serve every camera. Remove the '%s' model and list its devices under the '%s' model instead",
original.scene,
model.scene,
original.scene,
model.scene,
original.scene,
)
original.devices = [*original.devices, *model.devices]
model_devices[original.scene] = [
*model_devices[original.scene],
*model_devices.pop(model.scene),
]
self._scene_models[model.scene] = original
# anything already folded into the duplicate follows it
for scene, target in self._scene_models.items():
if target is model:
self._scene_models[scene] = original
self.models = kept
def _resolve_camera_model(self, name: str, scene: str) -> ModelConfig:
"""Resolve which model a camera runs on. """Resolve which model a camera runs on.
A camera may name a scene no model is configured for, which is valid as A camera may name a scene no model is configured for, which is valid as
long as an 'all' model is there to fall back to. long as a 'default' model is there to fall back to.
Args: Args:
name: Name of the camera name: Name of the camera
scene: The camera's detect scene, which defaults to 'all' scene: The camera's detect scene, which defaults to 'default'
Returns: Returns:
The model the camera runs on The model the camera runs on
""" """
by_scene = {model.scene: model for model in self.models} model = self._scene_models.get(scene)
model = by_scene.get(scene)
if model is not None: if model is not None:
return model return model
default = by_scene.get(SceneEnum.all) default = self._scene_models.get(DEFAULT_SCENE)
if default is None: if default is None:
raise ValueError( raise ValueError(
f"Camera '{name}' has a detect scene of '{scene.value}', but no model is configured for that scene or for 'all'." f"Camera '{name}' has a detect scene of '{scene}', but no model is configured for that scene or for '{DEFAULT_SCENE}'."
) )
logger.warning( logger.warning(
"Camera '%s' has a detect scene of '%s', but no model is configured for that scene, so the 'all' model is used", "Camera '%s' has a detect scene of '%s', but no model is configured for that scene, so the '%s' model is used",
name, name,
scene.value, scene,
DEFAULT_SCENE,
) )
return default return default
@@ -998,6 +1075,10 @@ class FrigateConfig(FrigateBaseModel):
camera_model = self._resolve_camera_model(name, camera_config.detect.scene) camera_model = self._resolve_camera_model(name, camera_config.detect.scene)
self._camera_models[name] = camera_model self._camera_models[name] = camera_model
# point cameras at the model their duplicate scene was folded into
if camera_config.detect.scene in self._scene_models:
camera_config.detect.scene = camera_model.scene
if camera_config.ffmpeg.hwaccel_args == "auto": if camera_config.ffmpeg.hwaccel_args == "auto":
camera_config.ffmpeg.hwaccel_args = self.ffmpeg.hwaccel_args camera_config.ffmpeg.hwaccel_args = self.ffmpeg.hwaccel_args
+7 -11
View File
@@ -47,21 +47,17 @@ class ModelTypeEnum(str, Enum):
yologeneric = "yolo-generic" yologeneric = "yolo-generic"
class SceneEnum(str, Enum): # the scene of the model used by cameras that don't name one
"""The camera environment a detection model is intended for.""" DEFAULT_SCENE = "default"
SCENE_PATTERN = r"^[A-Za-z0-9_-]+$"
all = "all"
indoor = "indoor"
outdoor = "outdoor"
indoor_thermal = "indoor_thermal"
outdoor_thermal = "outdoor_thermal"
class ModelConfig(BaseModel): class ModelConfig(BaseModel):
scene: SceneEnum = Field( scene: str = Field(
default=SceneEnum.all, default=DEFAULT_SCENE,
pattern=SCENE_PATTERN,
title="Model scene", title="Model scene",
description="The camera environment this model is used for. Cameras select a model by setting detect.scene to a matching value, and 'all' is used by any camera that does not set one.", description="A name for the camera environment this model is used for, such as 'thermal'. Cameras select a model by setting detect.scene to a matching value, and the 'default' model is used by any camera that does not set one.",
) )
devices: list[str] = Field( devices: list[str] = Field(
default_factory=list, default_factory=list,
+2 -3
View File
@@ -9,7 +9,6 @@ from frigate.config.camera.updater import (
CameraConfigUpdateSubscriber, CameraConfigUpdateSubscriber,
) )
from frigate.const import SUB_CACHE_TAG from frigate.const import SUB_CACHE_TAG
from frigate.detectors.detector_config import SceneEnum
def _build_scene_frigate_config(scene: str | None) -> FrigateConfig: def _build_scene_frigate_config(scene: str | None) -> FrigateConfig:
@@ -119,7 +118,7 @@ class TestRecordUpdateRecreatesFfmpegCmds(unittest.TestCase):
subscriber = CameraConfigUpdateSubscriber( subscriber = CameraConfigUpdateSubscriber(
config, {}, [CameraConfigUpdateEnum.add, CameraConfigUpdateEnum.remove] config, {}, [CameraConfigUpdateEnum.add, CameraConfigUpdateEnum.remove]
) )
assert config.model_for_camera("front_door").scene == SceneEnum.outdoor assert config.model_for_camera("front_door").scene == "outdoor"
subscriber.subscriber.check_for_update.side_effect = [ subscriber.subscriber.check_for_update.side_effect = [
("config/cameras/front_door/remove", config.cameras["front_door"]), ("config/cameras/front_door/remove", config.cameras["front_door"]),
@@ -136,7 +135,7 @@ class TestRecordUpdateRecreatesFfmpegCmds(unittest.TestCase):
] ]
subscriber.check_for_updates() subscriber.check_for_updates()
assert config.model_for_camera("front_door").scene == SceneEnum.all assert config.model_for_camera("front_door").scene == "default"
def test_unchanged_record_update_keeps_existing_cmds(self): def test_unchanged_record_update_keeps_existing_cmds(self):
camera_config = _build_camera_config(sub_enabled=False) camera_config = _build_camera_config(sub_enabled=False)
+125 -7
View File
@@ -1,5 +1,6 @@
import json import json
import os import os
import tempfile
import unittest import unittest
from copy import deepcopy from copy import deepcopy
from unittest.mock import patch from unittest.mock import patch
@@ -11,7 +12,6 @@ from ruamel.yaml.constructor import DuplicateKeyError
from frigate.config import BirdseyeModeEnum, FrigateConfig, RetainModeEnum from frigate.config import BirdseyeModeEnum, FrigateConfig, RetainModeEnum
from frigate.const import MODEL_CACHE_DIR from frigate.const import MODEL_CACHE_DIR
from frigate.detectors import DetectorTypeEnum from frigate.detectors import DetectorTypeEnum
from frigate.detectors.detector_config import SceneEnum
from frigate.detectors.device import build_detector_config, runner_names from frigate.detectors.device import build_detector_config, runner_names
from frigate.util.builtin import deep_merge from frigate.util.builtin import deep_merge
@@ -68,7 +68,7 @@ class TestConfig(unittest.TestCase):
def test_config_class(self): def test_config_class(self):
frigate_config = FrigateConfig(**self.minimal) frigate_config = FrigateConfig(**self.minimal)
model = frigate_config.primary_model model = frigate_config.primary_model
assert model.scene == SceneEnum.all assert model.scene == "default"
assert model.width == 320 assert model.width == 320
assert frigate_config.devices_for_model(model)[0].detector == ( assert frigate_config.devices_for_model(model)[0].detector == (
DetectorTypeEnum.cpu DetectorTypeEnum.cpu
@@ -140,8 +140,8 @@ class TestConfig(unittest.TestCase):
frigate_config = FrigateConfig(**(deep_merge(config, self.minimal))) frigate_config = FrigateConfig(**(deep_merge(config, self.minimal)))
assert frigate_config.model_for_camera("back").scene == SceneEnum.outdoor assert frigate_config.model_for_camera("back").scene == "outdoor"
assert frigate_config.model_for_camera("front").scene == SceneEnum.indoor assert frigate_config.model_for_camera("front").scene == "indoor"
assert frigate_config.model_for_camera("back").width == 320 assert frigate_config.model_for_camera("back").width == 320
assert frigate_config.model_for_camera("front").width == 300 assert frigate_config.model_for_camera("front").width == 300
@@ -177,7 +177,7 @@ class TestConfig(unittest.TestCase):
frigate_config = FrigateConfig(**(deep_merge(config, self.minimal))) frigate_config = FrigateConfig(**(deep_merge(config, self.minimal)))
assert frigate_config.model_for_camera("back").scene == SceneEnum.all assert frigate_config.model_for_camera("back").scene == "default"
@patch("frigate.detectors.detector_config.load_labels") @patch("frigate.detectors.detector_config.load_labels")
def test_model_for_camera_resolves_camera_added_after_parse(self, mock_labels): def test_model_for_camera_resolves_camera_added_after_parse(self, mock_labels):
@@ -205,7 +205,7 @@ class TestConfig(unittest.TestCase):
new_config = FrigateConfig(**(deep_merge(deepcopy(config), added))) new_config = FrigateConfig(**(deep_merge(deepcopy(config), added)))
frigate_config.cameras["new_cam"] = new_config.cameras["new_cam"] frigate_config.cameras["new_cam"] = new_config.cameras["new_cam"]
assert frigate_config.model_for_camera("new_cam").scene == SceneEnum.outdoor assert frigate_config.model_for_camera("new_cam").scene == "outdoor"
assert frigate_config.model_for_camera("new_cam").width == 416 assert frigate_config.model_for_camera("new_cam").width == 416
@patch("frigate.detectors.detector_config.load_labels") @patch("frigate.detectors.detector_config.load_labels")
@@ -221,7 +221,7 @@ class TestConfig(unittest.TestCase):
frigate_config = FrigateConfig(**(deep_merge(deepcopy(config), self.minimal))) frigate_config = FrigateConfig(**(deep_merge(deepcopy(config), self.minimal)))
# a caller racing a runtime remove may still name the popped camera # a caller racing a runtime remove may still name the popped camera
assert frigate_config.model_for_camera("removed").scene == SceneEnum.all assert frigate_config.model_for_camera("removed").scene == "default"
@patch("frigate.detectors.detector_config.load_labels") @patch("frigate.detectors.detector_config.load_labels")
def test_camera_scene_without_a_model_or_a_default(self, mock_labels): def test_camera_scene_without_a_model_or_a_default(self, mock_labels):
@@ -256,6 +256,124 @@ class TestConfig(unittest.TestCase):
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
FrigateConfig(**(deep_merge(config, self.minimal))) FrigateConfig(**(deep_merge(config, self.minimal)))
@patch("frigate.detectors.detector_config.load_labels")
def test_scene_names_are_not_a_fixed_list(self, mock_labels):
mock_labels.return_value = {}
config = {
"models": [
{"devices": ["cpu"]},
{"scene": "garage_thermal", "devices": ["openvino:CPU"]},
],
"cameras": {"back": {"detect": {"scene": "garage_thermal"}}},
}
frigate_config = FrigateConfig(**(deep_merge(config, self.minimal)))
assert frigate_config.model_for_camera("back").scene == "garage_thermal"
@patch("frigate.detectors.detector_config.load_labels")
def test_scene_names_must_be_simple_identifiers(self, mock_labels):
mock_labels.return_value = {}
config = {"models": [{"scene": "front yard", "devices": ["cpu"]}]}
with self.assertRaises(ValidationError):
FrigateConfig(**(deep_merge(config, self.minimal)))
def _two_scene_config(self, default_path: str, outdoor_path: str) -> dict:
return {
"models": [
{"path": default_path, "devices": ["openvino:CPU"]},
{"scene": "outdoor", "path": outdoor_path, "devices": ["openvino:GPU"]},
],
"cameras": {"back": {"detect": {"scene": "outdoor"}}},
}
@patch("frigate.detectors.detector_config.load_labels")
def test_models_with_the_same_path_are_combined(self, mock_labels):
mock_labels.return_value = {}
config = self._two_scene_config("/etc/hosts", "/etc/hosts")
with self.assertLogs("frigate.config.config", level="WARNING") as logs:
frigate_config = FrigateConfig(**(deep_merge(config, self.minimal)))
assert len(frigate_config.models) == 1
model = frigate_config.primary_model
assert model.devices == ["openvino:CPU", "openvino:GPU"]
assert [d.raw for d in frigate_config.devices_for_model(model)] == [
"openvino:CPU",
"openvino:GPU",
]
assert frigate_config.model_for_camera("back") is model
assert frigate_config.cameras["back"].detect.scene == "default"
assert any("same model file" in line for line in logs.output)
@patch("frigate.detectors.detector_config.load_labels")
def test_models_with_the_same_file_contents_are_combined(self, mock_labels):
mock_labels.return_value = {}
with tempfile.TemporaryDirectory() as temp_dir:
first = os.path.join(temp_dir, "model.onnx")
copy = os.path.join(temp_dir, "renamed.onnx")
for path in (first, copy):
with open(path, "wb") as f:
f.write(b"same weights")
config = self._two_scene_config(first, copy)
frigate_config = FrigateConfig(**(deep_merge(config, self.minimal)))
assert len(frigate_config.models) == 1
assert frigate_config.model_for_camera("back").scene == "default"
@patch("frigate.detectors.detector_config.load_labels")
def test_models_with_different_files_are_kept_apart(self, mock_labels):
mock_labels.return_value = {}
with tempfile.TemporaryDirectory() as temp_dir:
first = os.path.join(temp_dir, "model.onnx")
other = os.path.join(temp_dir, "thermal.onnx")
for path, contents in ((first, b"visible"), (other, b"thermal")):
with open(path, "wb") as f:
f.write(contents)
config = self._two_scene_config(first, other)
frigate_config = FrigateConfig(**(deep_merge(config, self.minimal)))
assert len(frigate_config.models) == 2
assert frigate_config.model_for_camera("back").scene == "outdoor"
@patch("frigate.detectors.detector_config.load_labels")
def test_combined_models_keep_the_default_model(self, mock_labels):
mock_labels.return_value = {}
config = {
"models": [
{"scene": "outdoor", "path": "/etc/hosts", "devices": ["openvino:GPU"]},
{"path": "/etc/hosts", "devices": ["openvino:CPU"]},
],
"cameras": {"back": {"detect": {"scene": "outdoor"}}},
}
frigate_config = FrigateConfig(**(deep_merge(config, self.minimal)))
assert [model.scene for model in frigate_config.models] == ["default"]
assert frigate_config.primary_model.devices == ["openvino:CPU", "openvino:GPU"]
assert frigate_config.model_for_camera("back").scene == "default"
@patch("frigate.detectors.detector_config.load_labels")
def test_models_on_different_detectors_are_kept_apart(self, mock_labels):
mock_labels.return_value = {}
config = {
"models": [
{"path": "/etc/hosts", "devices": ["openvino:CPU"]},
{"scene": "outdoor", "path": "/etc/hosts", "devices": ["onnx"]},
],
}
frigate_config = FrigateConfig(**(deep_merge(config, self.minimal)))
assert len(frigate_config.models) == 2
@patch("frigate.detectors.detector_config.load_labels") @patch("frigate.detectors.detector_config.load_labels")
def test_model_devices_must_share_a_detector(self, mock_labels): def test_model_devices_must_share_a_detector(self, mock_labels):
mock_labels.return_value = {} mock_labels.return_value = {}
+5 -5
View File
@@ -20,7 +20,7 @@ class TestMigrateModels(unittest.TestCase):
def test_single_cpu_detector(self): def test_single_cpu_detector(self):
migrated = migrate_models({"detectors": {"cpu": {"type": "cpu"}}}) migrated = migrate_models({"detectors": {"cpu": {"type": "cpu"}}})
self.assertEqual(migrated["models"], [{"scene": "all", "devices": ["cpu"]}]) self.assertEqual(migrated["models"], [{"scene": "default", "devices": ["cpu"]}])
self.assertNotIn("detectors", migrated) self.assertNotIn("detectors", migrated)
def test_model_settings_are_carried_over(self): def test_model_settings_are_carried_over(self):
@@ -35,7 +35,7 @@ class TestMigrateModels(unittest.TestCase):
migrated["models"], migrated["models"],
[ [
{ {
"scene": "all", "scene": "default",
"path": "plus://abc", "path": "plus://abc",
"width": 320, "width": 320,
"devices": ["edgetpu:pci:0"], "devices": ["edgetpu:pci:0"],
@@ -262,7 +262,7 @@ class TestMigrateConfigFile(unittest.TestCase):
"mqtt:\n" "mqtt:\n"
" enabled: false\n" " enabled: false\n"
"models:\n" "models:\n"
" - scene: all\n" " - scene: default\n"
" devices:\n" " devices:\n"
" - openvino:GPU\n" " - openvino:GPU\n"
"cameras: {}\n" "cameras: {}\n"
@@ -270,7 +270,7 @@ class TestMigrateConfigFile(unittest.TestCase):
) )
self.assertEqual( self.assertEqual(
migrated["models"], [{"scene": "all", "devices": ["openvino:GPU"]}] migrated["models"], [{"scene": "default", "devices": ["openvino:GPU"]}]
) )
self.assertFalse( self.assertFalse(
os.path.exists(os.path.join(self.temp_dir.name, "backup_config.yaml")) os.path.exists(os.path.join(self.temp_dir.name, "backup_config.yaml"))
@@ -298,7 +298,7 @@ class TestMigrateConfigFile(unittest.TestCase):
"mqtt:\n" "mqtt:\n"
" enabled: false\n" " enabled: false\n"
"models:\n" "models:\n"
" - scene: all\n" " - scene: default\n"
" devices:\n" " devices:\n"
" - hailo8l:PCIe\n" " - hailo8l:PCIe\n"
"cameras: {}\n" "cameras: {}\n"
+1 -1
View File
@@ -978,7 +978,7 @@ def migrate_models(config: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any
", ".join(sorted(detector_types)), ", ".join(sorted(detector_types)),
) )
entry: dict[str, Any] = {"scene": "all", **model} entry: dict[str, Any] = {"scene": "default", **model}
if model_path: if model_path:
entry["path"] = model_path entry["path"] = model_path
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,135 @@
/**
* Camera detect scene tests -- MEDIUM tier.
*
* Scenes are free-form names declared by the configured models, so a camera
* only needs to pick one when there is a choice to make. The choices are the
* scenes of the configured models, plus a saved scene that no model uses.
*/
import { readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { test, expect } from "../../fixtures/frigate-test";
import type { Page } from "@playwright/test";
import { configFactory } from "../../fixtures/mock-data/config";
const __dirname = dirname(fileURLToPath(import.meta.url));
const CONFIG_SCHEMA = JSON.parse(
readFileSync(
resolve(__dirname, "../../fixtures/mock-data/config-schema.json"),
"utf-8",
),
);
const SETTINGS_URL = "/settings?page=cameraDetect&camera=front_door";
async function installRoutes(
page: Page,
models: { scene: string; devices: string[] }[],
cameraScene?: string,
) {
const config = configFactory({
models,
...(cameraScene
? { cameras: { front_door: { detect: { scene: cameraScene } } } }
: {}),
} as never);
await page.route("**/api/config/schema.json", (route) =>
route.fulfill({ json: CONFIG_SCHEMA }),
);
await page.route("**/api/config", (route) =>
route.request().method() === "GET"
? route.fulfill({ json: config })
: route.fulfill({ json: { success: true } }),
);
await page.route("**/api/config/raw_paths", (route) =>
route.fulfill({ json: {} }),
);
}
test.describe("camera detect scene @medium", () => {
test("is hidden when there is only one model", async ({ frigateApp }) => {
await installRoutes(frigateApp.page, [
{ scene: "default", devices: ["cpu"] },
]);
await frigateApp.goto(SETTINGS_URL);
const root = frigateApp.page.locator("#pageRoot");
await expect(root).toContainText("Detect FPS");
await expect(root).not.toContainText("Detect scene");
});
test("offers the default and configured model scenes", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, [
{ scene: "default", devices: ["cpu"] },
{ scene: "thermal", devices: ["openvino:GPU.0"] },
]);
await frigateApp.goto(SETTINGS_URL);
const root = frigateApp.page.locator("#pageRoot");
await expect(root).toContainText("Detect scene");
await frigateApp.page.locator("#root_scene").click();
const options = frigateApp.page.getByRole("option");
await expect(options).toHaveText(["Default", "thermal"]);
});
test("a saved scene no model uses stays editable", async ({ frigateApp }) => {
// the camera falls back to the default model, but its saved scene would
// silently take effect if a model for it were added later
await installRoutes(
frigateApp.page,
[{ scene: "default", devices: ["cpu"] }],
"garage",
);
await frigateApp.goto(SETTINGS_URL);
await expect(frigateApp.page.locator("#pageRoot")).toContainText(
"Detect scene",
);
await frigateApp.page.locator("#root_scene").click();
await expect(frigateApp.page.getByRole("option")).toHaveText([
"Default",
"garage",
]);
});
test("default is only offered when a default model exists", async ({
frigateApp,
}) => {
await installRoutes(
frigateApp.page,
[
{ scene: "thermal", devices: ["cpu"] },
{ scene: "visible", devices: ["openvino:GPU.0"] },
],
"thermal",
);
await frigateApp.goto(SETTINGS_URL);
await frigateApp.page.locator("#root_scene").click();
await expect(frigateApp.page.getByRole("option")).toHaveText([
"thermal",
"visible",
]);
});
test("one model every camera selects needs no choice", async ({
frigateApp,
}) => {
await installRoutes(
frigateApp.page,
[{ scene: "thermal", devices: ["cpu"] }],
"thermal",
);
await frigateApp.goto(SETTINGS_URL);
const root = frigateApp.page.locator("#pageRoot");
await expect(root).toContainText("Detect FPS");
await expect(root).not.toContainText("Detect scene");
});
});
+154 -28
View File
@@ -129,19 +129,19 @@ const openPage = async (frigateApp: {
test.describe("Detection models settings @high", () => { test.describe("Detection models settings @high", () => {
test("renders a card per configured model", async ({ frigateApp }) => { test("renders a card per configured model", async ({ frigateApp }) => {
await installRoutes(frigateApp.page, [ await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["cpu"] }, { scene: "default", devices: ["cpu"] },
{ scene: "outdoor", devices: ["edgetpu:pci:0"] }, { scene: "outdoor", devices: ["edgetpu:pci:0"] },
]); ]);
await openPage(frigateApp); await openPage(frigateApp);
const root = frigateApp.page.locator("#pageRoot"); const root = frigateApp.page.locator("#pageRoot");
await expect(root).toContainText("All cameras"); await expect(root).toContainText("Default");
await expect(root).toContainText("Outdoor"); await expect(root).toContainText("outdoor");
}); });
test("unlimited hardware offers a detector count", async ({ frigateApp }) => { test("unlimited hardware offers a detector count", async ({ frigateApp }) => {
await installRoutes(frigateApp.page, [ await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["openvino:GPU.0"] }, { scene: "default", devices: ["openvino:GPU.0"] },
]); ]);
await openPage(frigateApp); await openPage(frigateApp);
@@ -169,7 +169,7 @@ test.describe("Detection models settings @high", () => {
frigateApp, frigateApp,
}) => { }) => {
await installRoutes(frigateApp.page, [ await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["openvino:GPU.0", "openvino:GPU.0"] }, { scene: "default", devices: ["openvino:GPU.0", "openvino:GPU.0"] },
]); ]);
await openPage(frigateApp); await openPage(frigateApp);
@@ -190,7 +190,7 @@ test.describe("Detection models settings @high", () => {
frigateApp, frigateApp,
}) => { }) => {
await installRoutes(frigateApp.page, [ await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["edgetpu:pci:0"] }, { scene: "default", devices: ["edgetpu:pci:0"] },
]); ]);
await openPage(frigateApp); await openPage(frigateApp);
@@ -206,7 +206,7 @@ test.describe("Detection models settings @high", () => {
frigateApp, frigateApp,
}) => { }) => {
await installRoutes(frigateApp.page, [ await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["edgetpu:pci:0"] }, { scene: "default", devices: ["edgetpu:pci:0"] },
{ scene: "outdoor", devices: ["edgetpu:pci:1"] }, { scene: "outdoor", devices: ["edgetpu:pci:1"] },
]); ]);
await openPage(frigateApp); await openPage(frigateApp);
@@ -221,7 +221,7 @@ test.describe("Detection models settings @high", () => {
frigateApp, frigateApp,
}) => { }) => {
await installRoutes(frigateApp.page, [ await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["openvino:GPU.0"] }, { scene: "default", devices: ["openvino:GPU.0"] },
{ scene: "outdoor", devices: ["openvino:GPU.1"] }, { scene: "outdoor", devices: ["openvino:GPU.1"] },
]); ]);
await openPage(frigateApp); await openPage(frigateApp);
@@ -234,23 +234,141 @@ test.describe("Detection models settings @high", () => {
); );
}); });
test("adding a model appends a card with an unused scene", async ({ test("adding a model appends a card with a scene to name", async ({
frigateApp, frigateApp,
}) => { }) => {
await installRoutes(frigateApp.page, [{ scene: "all", devices: ["cpu"] }]); await installRoutes(frigateApp.page, [
{ scene: "default", devices: ["cpu"] },
]);
await openPage(frigateApp); await openPage(frigateApp);
await frigateApp.page.getByRole("button", { name: "Add model" }).click(); await frigateApp.page.getByRole("button", { name: "Add model" }).click();
// "all" is taken, so the new card takes the next available scene // the new card's scene starts empty, and a scene is required to save
await expect(frigateApp.page.locator("#pageRoot")).toContainText("Indoor"); const scene = frigateApp.page.locator("#models-1-scene");
await expect(scene).toHaveValue("");
await expect(
frigateApp.page.getByRole("button", { name: /^Save$/ }),
).toBeDisabled();
// the card keeps focus while its scene is typed
await scene.fill("thermal");
await expect(scene).toHaveValue("thermal");
await expect(scene).toBeFocused();
});
test("one model file cannot be split across scenes", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, [
{
scene: "default",
devices: ["openvino:GPU.0"],
path: "/config/model_cache/yolo.onnx",
},
{
scene: "driveway",
devices: ["openvino:GPU.1"],
path: "/config/model_cache/other.onnx",
},
]);
await openPage(frigateApp);
await frigateApp.page
.locator("#root_1_path")
.fill("/config/model_cache/yolo.onnx");
await expect(frigateApp.page.locator("#pageRoot")).toContainText(
"The Default and driveway models use the same model file",
);
await expect(
frigateApp.page.getByRole("button", { name: /^Save$/ }),
).toBeDisabled();
});
test("two models without a path share the detector's default model", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, [
{ scene: "default", devices: ["cpu"] },
{ scene: "driveway", devices: ["cpu"] },
]);
await openPage(frigateApp);
await expect(frigateApp.page.locator("#pageRoot")).toContainText(
"The Default and driveway models use the same model file",
);
});
test("another spelling of the same path is the same model", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, [
{
scene: "default",
devices: ["openvino:GPU.0"],
path: "/config/model_cache/yolo.onnx",
},
{
scene: "driveway",
devices: ["openvino:GPU.1"],
path: "/config//model_cache/./tmp/../yolo.onnx",
},
]);
await openPage(frigateApp);
await expect(frigateApp.page.locator("#pageRoot")).toContainText(
"The Default and driveway models use the same model file",
);
});
test("one model file may run on different detectors", async ({
frigateApp,
}) => {
await installRoutes(frigateApp.page, [
{
scene: "default",
devices: ["openvino:GPU.0"],
path: "/config/model_cache/yolo.onnx",
},
{
scene: "driveway",
devices: ["onnx"],
path: "/config/model_cache/yolo.onnx",
},
]);
await openPage(frigateApp);
await expect(frigateApp.page.locator("#pageRoot")).toContainText(
"driveway",
);
await expect(frigateApp.page.locator("#pageRoot")).not.toContainText(
"models use the same model file",
);
});
test("two models cannot share a scene", async ({ frigateApp }) => {
await installRoutes(frigateApp.page, [
{ scene: "default", devices: ["cpu"] },
{ scene: "thermal", devices: ["edgetpu:pci:0"] },
]);
await openPage(frigateApp);
await frigateApp.page.locator("#models-1-scene").fill("default");
await expect(frigateApp.page.locator("#pageRoot")).toContainText(
"Each model must use a different scene",
);
await expect(
frigateApp.page.getByRole("button", { name: /^Save$/ }),
).toBeDisabled();
}); });
test("hardware is summarized rather than listed device by device", async ({ test("hardware is summarized rather than listed device by device", async ({
frigateApp, frigateApp,
}) => { }) => {
await installRoutes(frigateApp.page, [ await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["openvino:GPU.0", "openvino:GPU.0"] }, { scene: "default", devices: ["openvino:GPU.0", "openvino:GPU.0"] },
]); ]);
await openPage(frigateApp); await openPage(frigateApp);
@@ -269,7 +387,7 @@ test.describe("Detection models settings @high", () => {
frigateApp.page, frigateApp.page,
[ [
{ {
scene: "all", scene: "default",
devices: ["openvino:GPU.0"], devices: ["openvino:GPU.0"],
path: "plus://abc123", path: "plus://abc123",
plus: PLUS_MODEL, plus: PLUS_MODEL,
@@ -294,7 +412,7 @@ test.describe("Detection models settings @high", () => {
frigateApp.page, frigateApp.page,
[ [
{ {
scene: "all", scene: "default",
devices: ["openvino:GPU.0"], devices: ["openvino:GPU.0"],
path: "/config/custom.onnx", path: "/config/custom.onnx",
}, },
@@ -325,7 +443,7 @@ test.describe("Detection models settings @high", () => {
frigateApp.page, frigateApp.page,
[ [
{ {
scene: "all", scene: "default",
devices: ["openvino:GPU.0"], devices: ["openvino:GPU.0"],
path: "plus://abc123", path: "plus://abc123",
plus: PLUS_MODEL, plus: PLUS_MODEL,
@@ -359,7 +477,7 @@ test.describe("Detection models settings @high", () => {
}) => { }) => {
await installRoutes(frigateApp.page, [ await installRoutes(frigateApp.page, [
{ {
scene: "all", scene: "default",
devices: ["openvino:GPU.0"], devices: ["openvino:GPU.0"],
path: "plus://abc123", path: "plus://abc123",
width: 320, width: 320,
@@ -383,7 +501,13 @@ test.describe("Detection models settings @high", () => {
// says nothing; which device it was built for is what the user picks on // says nothing; which device it was built for is what the user picks on
await installRoutes( await installRoutes(
frigateApp.page, frigateApp.page,
[{ scene: "all", devices: ["hailo:PCIe"], path: "/config/custom.hef" }], [
{
scene: "default",
devices: ["hailo:PCIe"],
path: "/config/custom.hef",
},
],
true, true,
HAILO_PLUS_MODELS, HAILO_PLUS_MODELS,
true, true,
@@ -413,7 +537,7 @@ test.describe("Detection models settings @high", () => {
// that must not read as an edit. // that must not read as an edit.
await installRoutes(frigateApp.page, [ await installRoutes(frigateApp.page, [
{ {
scene: "all", scene: "default",
devices: ["openvino:GPU.0", "openvino:GPU.0"], devices: ["openvino:GPU.0", "openvino:GPU.0"],
path: "/config/model_cache/abc123", path: "/config/model_cache/abc123",
width: 320, width: 320,
@@ -438,12 +562,14 @@ test.describe("Detection models settings @high", () => {
frigateApp, frigateApp,
}) => { }) => {
await installRoutes(frigateApp.page, [ await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["openvino:GPU.0"] }, { scene: "default", devices: ["openvino:GPU.0"] },
]); ]);
await openPage(frigateApp); await openPage(frigateApp);
const root = frigateApp.page.locator("#pageRoot"); const root = frigateApp.page.locator("#pageRoot");
await expect(root).toContainText("The environment this model is for"); await expect(root).toContainText(
"A name for the cameras this model is for",
);
await expect(root).toContainText( await expect(root).toContainText(
"The hardware this model runs its detection on", "The hardware this model runs its detection on",
); );
@@ -454,7 +580,7 @@ test.describe("Detection models settings @high", () => {
frigateApp, frigateApp,
}) => { }) => {
await installRoutes(frigateApp.page, [ await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["edgetpu:pci:0"] }, { scene: "default", devices: ["edgetpu:pci:0"] },
]); ]);
await openPage(frigateApp); await openPage(frigateApp);
@@ -465,10 +591,10 @@ test.describe("Detection models settings @high", () => {
}); });
test("removing the default model blocks saving", async ({ frigateApp }) => { test("removing the default model blocks saving", async ({ frigateApp }) => {
// a camera that names no scene runs the "all" model, so deleting it would // a camera that names no scene runs the default model, so deleting it would
// leave those cameras with nothing to fall back to // leave those cameras with nothing to fall back to
await installRoutes(frigateApp.page, [ await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["cpu"] }, { scene: "default", devices: ["cpu"] },
{ scene: "outdoor", devices: ["edgetpu:pci:0"] }, { scene: "outdoor", devices: ["edgetpu:pci:0"] },
]); ]);
await openPage(frigateApp); await openPage(frigateApp);
@@ -479,7 +605,7 @@ test.describe("Detection models settings @high", () => {
.click(); .click();
await expect(frigateApp.page.locator("#pageRoot")).toContainText( await expect(frigateApp.page.locator("#pageRoot")).toContainText(
"One model must use a scene of 'All cameras'", "One model must use the default scene",
); );
await expect( await expect(
frigateApp.page.getByRole("button", { name: /^Save$/ }), frigateApp.page.getByRole("button", { name: /^Save$/ }),
@@ -490,7 +616,7 @@ test.describe("Detection models settings @high", () => {
// shareable hardware can report several addressable units; every one of // shareable hardware can report several addressable units; every one of
// them must be reachable, not just the first // them must be reachable, not just the first
const saves = await installRoutes(frigateApp.page, [ const saves = await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["openvino:GPU.0"] }, { scene: "default", devices: ["openvino:GPU.0"] },
]); ]);
await openPage(frigateApp); await openPage(frigateApp);
@@ -508,7 +634,7 @@ test.describe("Detection models settings @high", () => {
frigateApp, frigateApp,
}) => { }) => {
const saves = await installRoutes(frigateApp.page, [ const saves = await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["openvino:GPU.0", "openvino:GPU.1"] }, { scene: "default", devices: ["openvino:GPU.0", "openvino:GPU.1"] },
]); ]);
await openPage(frigateApp); await openPage(frigateApp);
@@ -531,7 +657,7 @@ test.describe("Detection models settings @high", () => {
frigateApp, frigateApp,
}) => { }) => {
const saves = await installRoutes(frigateApp.page, [ const saves = await installRoutes(frigateApp.page, [
{ scene: "all", devices: ["edgetpu:pci:0"] }, { scene: "default", devices: ["edgetpu:pci:0"] },
]); ]);
await openPage(frigateApp); await openPage(frigateApp);
+5 -5
View File
@@ -483,7 +483,7 @@ test.describe("System — Health hardware pane @medium", () => {
frigateApp, frigateApp,
}) => { }) => {
await frigateApp.installDefaults({ await frigateApp.installDefaults({
config: { models: [{ scene: "all", devices: ["openvino:GPU"] }] }, config: { models: [{ scene: "default", devices: ["openvino:GPU"] }] },
stats: { stats: {
...QUIET_STATS, ...QUIET_STATS,
detectors: { "openvino:GPU": { inference_speed: 12.3 } }, detectors: { "openvino:GPU": { inference_speed: 12.3 } },
@@ -500,7 +500,7 @@ test.describe("System — Health hardware pane @medium", () => {
frigateApp, frigateApp,
}) => { }) => {
await frigateApp.installDefaults({ await frigateApp.installDefaults({
config: { models: [{ scene: "all", devices: ["hailo"] }] }, config: { models: [{ scene: "default", devices: ["hailo"] }] },
stats: QUIET_STATS, stats: QUIET_STATS,
}); });
await frigateApp.goto("/system#health"); await frigateApp.goto("/system#health");
@@ -516,7 +516,7 @@ test.describe("System — Health hardware pane @medium", () => {
frigateApp, frigateApp,
}) => { }) => {
await frigateApp.installDefaults({ await frigateApp.installDefaults({
config: { models: [{ scene: "all", devices: ["openvino:AUTO"] }] }, config: { models: [{ scene: "default", devices: ["openvino:AUTO"] }] },
stats: { stats: {
...QUIET_STATS, ...QUIET_STATS,
detectors: { "openvino:AUTO": { inference_speed: 12 } }, detectors: { "openvino:AUTO": { inference_speed: 12 } },
@@ -536,7 +536,7 @@ test.describe("System — Health hardware pane @medium", () => {
}) => { }) => {
// the default image runs onnx on the CPU and the probe reports nothing // the default image runs onnx on the CPU and the probe reports nothing
await frigateApp.installDefaults({ await frigateApp.installDefaults({
config: { models: [{ scene: "all", devices: ["onnx"] }] }, config: { models: [{ scene: "default", devices: ["onnx"] }] },
stats: { ...QUIET_STATS, detectors: { onnx: { inference_speed: 40 } } }, stats: { ...QUIET_STATS, detectors: { onnx: { inference_speed: 40 } } },
}); });
await frigateApp.goto("/system#health"); await frigateApp.goto("/system#health");
@@ -549,7 +549,7 @@ test.describe("System — Health hardware pane @medium", () => {
test("detection row warns on slow inference", async ({ frigateApp }) => { test("detection row warns on slow inference", async ({ frigateApp }) => {
await frigateApp.installDefaults({ await frigateApp.installDefaults({
config: { models: [{ scene: "all", devices: ["openvino:GPU"] }] }, config: { models: [{ scene: "default", devices: ["openvino:GPU"] }] },
stats: { stats: {
...QUIET_STATS, ...QUIET_STATS,
detectors: { "openvino:GPU": { inference_speed: 60 } }, detectors: { "openvino:GPU": { inference_speed: 60 } },
+1 -1
View File
@@ -100,7 +100,7 @@
}, },
"scene": { "scene": {
"label": "Detect scene", "label": "Detect scene",
"description": "The environment this camera looks at, used to pick which of the configured models runs on it. Cameras left on 'all' run the model configured with a scene of 'all'." "description": "The environment this camera looks at, used to pick which of the configured models runs on it. Cameras left on 'default' run the model configured with a scene of 'default'."
}, },
"fps": { "fps": {
"label": "Detect FPS", "label": "Detect FPS",
+3 -3
View File
@@ -277,10 +277,10 @@
}, },
"models": { "models": {
"label": "Detection models", "label": "Detection models",
"description": "Object detection models and the hardware each one runs on. Cameras pick a model by matching their detect.scene against a model's scene.", "description": "Object detection models and the hardware each one runs on. Cameras pick a model by matching their detect.scene against a model's scene, falling back to the 'default' model.",
"scene": { "scene": {
"label": "Model scene", "label": "Model scene",
"description": "The camera environment this model is used for. Cameras select a model by setting detect.scene to a matching value, and 'all' is used by any camera that does not set one." "description": "A name for the camera environment this model is used for, such as 'thermal'. Cameras select a model by setting detect.scene to a matching value, and the 'default' model is used by any camera that does not set one."
}, },
"devices": { "devices": {
"label": "Detection hardware", "label": "Detection hardware",
@@ -468,7 +468,7 @@
}, },
"scene": { "scene": {
"label": "Detect scene", "label": "Detect scene",
"description": "The environment this camera looks at, used to pick which of the configured models runs on it. Cameras left on 'all' run the model configured with a scene of 'all'." "description": "The environment this camera looks at, used to pick which of the configured models runs on it. Cameras left on 'default' run the model configured with a scene of 'default'."
}, },
"fps": { "fps": {
"label": "Detect FPS", "label": "Detect FPS",
+3 -1
View File
@@ -33,6 +33,8 @@
"dimensionMustBeEven": "Must be an even number." "dimensionMustBeEven": "Must be an even number."
}, },
"models": { "models": {
"defaultRequired": "One model must use a scene of 'All cameras'. Without it, any camera that does not choose a scene has no model to fall back to." "defaultRequired": "One model must use the default scene. Without it, any camera that does not choose a scene has no model to fall back to.",
"sceneDuplicate": "Each model must use a different scene.",
"sameModel": "The {{scene}} and {{other}} models use the same model file. Running one model under several scenes only splits the detection work across separate detectors, which is slower than one model with all of the hardware listed under it."
} }
} }
+5 -8
View File
@@ -1986,7 +1986,7 @@
"fpsGreaterThanFive": "Setting the detect FPS higher than 5 is not recommended. Higher values may cause performance issues and will not provide any benefit.", "fpsGreaterThanFive": "Setting the detect FPS higher than 5 is not recommended. Higher values may cause performance issues and will not provide any benefit.",
"disabled": "Object detection is disabled. Snapshots, review items, and enrichments such as face recognition, license plate recognition, and Generative AI will not function.", "disabled": "Object detection is disabled. Snapshots, review items, and enrichments such as face recognition, license plate recognition, and Generative AI will not function.",
"runtimeDisabled": "Object detection is enabled in your config, but it is currently turned off for this camera. Snapshots, review items, and enrichments such as face recognition, license plate recognition, and Generative AI will not function until it is turned back on from the camera's live view, or until the active profile stops disabling it.", "runtimeDisabled": "Object detection is enabled in your config, but it is currently turned off for this camera. Snapshots, review items, and enrichments such as face recognition, license plate recognition, and Generative AI will not function until it is turned back on from the camera's live view, or until the active profile stops disabling it.",
"sceneWithoutModel": "No detection model is configured for this scene, so this camera falls back to the model with a scene of 'All cameras'. Add a model for this scene to give the camera its own.", "sceneWithoutModel": "No detection model is configured for this scene, so this camera falls back to the Default model. Add a model for this scene to give the camera its own.",
"resolutionShouldBeMultipleOfFour": "For best results, detect width and height should be multiples of 4. Other even values may produce visual artifacts or slight distortion in the detect stream.", "resolutionShouldBeMultipleOfFour": "For best results, detect width and height should be multiples of 4. Other even values may produce visual artifacts or slight distortion in the detect stream.",
"aspectRatioMismatch": "The width and height you've entered don't match the aspect ratio of your current detect resolution. This may produce a stretched or distorted image.", "aspectRatioMismatch": "The width and height you've entered don't match the aspect ratio of your current detect resolution. This may produce a stretched or distorted image.",
"maxFramesSet": "Setting max frames overrides default behavior and disables stationary object tracking. There are very few situations where this is needed, use with caution.", "maxFramesSet": "Setting max frames overrides default behavior and disables stationary object tracking. There are very few situations where this is needed, use with caution.",
@@ -2036,20 +2036,17 @@
}, },
"detectionModels": { "detectionModels": {
"title": "Detection models", "title": "Detection models",
"description": "Configure the object detection models and the hardware each one runs on. Cameras choose a model by their scene in the camera's detect settings.", "description": "Configure the object detection models and the hardware each one runs on. Add a model for a scene only when some cameras need a differently trained model, such as thermal cameras. Cameras choose a model by their scene in the camera's detect settings.",
"addModel": "Add model", "addModel": "Add model",
"cameras_one": "{{count}} camera", "cameras_one": "{{count}} camera",
"cameras_other": "{{count}} cameras", "cameras_other": "{{count}} cameras",
"scene": { "scene": {
"label": "Scene", "label": "Scene",
"description": "The environment this model is for. Cameras pick a model by setting the same scene in their detect settings, and the model with a scene of all is used by any camera that does not set one." "placeholder": "thermal",
"description": "A name for the cameras this model is for. Cameras pick a model by setting the same scene in their detect settings, and the Default model is used by any camera that does not set one. Use scenes to run different models, not to split one model across hardware, since models that use the same model file are combined."
}, },
"scenes": { "scenes": {
"all": "All cameras", "default": "Default"
"indoor": "Indoor",
"outdoor": "Outdoor",
"indoor_thermal": "Indoor thermal",
"outdoor_thermal": "Outdoor thermal"
}, },
"hardware": { "hardware": {
"label": "Hardware", "label": "Hardware",
@@ -1,5 +1,12 @@
import type { HiddenFieldContext } from "@/types/configForm";
import { DEFAULT_SCENE, getSceneChoices } from "@/utils/modelUtil";
import type { SectionConfigOverrides } from "./types"; import type { SectionConfigOverrides } from "./types";
// picking a scene only means something when there is more than one choice,
// which includes a saved scene that no model uses
const hideSceneWithoutChoice = (ctx: HiddenFieldContext): string[] =>
getSceneChoices(ctx).length > 1 ? [] : ["scene"];
const detect: SectionConfigOverrides = { const detect: SectionConfigOverrides = {
base: { base: {
sectionDocs: "/configuration/camera_specific", sectionDocs: "/configuration/camera_specific",
@@ -123,7 +130,7 @@ const detect: SectionConfigOverrides = {
docLink: "/configuration/object_detectors#running-more-than-one-model", docLink: "/configuration/object_detectors#running-more-than-one-model",
condition: (ctx) => { condition: (ctx) => {
const scene = ctx.formData?.scene as string | undefined; const scene = ctx.formData?.scene as string | undefined;
if (!scene || scene === "all") return false; if (!scene || scene === DEFAULT_SCENE) return false;
const models = ctx.fullConfig?.models; const models = ctx.fullConfig?.models;
if (!models) return false; if (!models) return false;
return !models.some((model) => model.scene === scene); return !models.some((model) => model.scene === scene);
@@ -194,6 +201,7 @@ const detect: SectionConfigOverrides = {
scene: { scene: {
"ui:options": { "ui:options": {
enumI18nPrefix: "detectionModels.scenes", enumI18nPrefix: "detectionModels.scenes",
enumI18nOptional: true,
}, },
}, },
annotation_offset: { annotation_offset: {
@@ -202,7 +210,7 @@ const detect: SectionConfigOverrides = {
}, },
}, },
}, },
hiddenFields: ["enabled_in_config"], hiddenFields: ["enabled_in_config", hideSceneWithoutChoice],
advancedFields: [ advancedFields: [
"min_initialized", "min_initialized",
"max_disappeared", "max_disappeared",
@@ -2,7 +2,7 @@ import type { FormValidation } from "@rjsf/utils";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import { validateDetectDimensions } from "./detect"; import { validateDetectDimensions } from "./detect";
import { validateFfmpegInputRoles } from "./ffmpeg"; import { validateFfmpegInputRoles } from "./ffmpeg";
import { validateDefaultModelExists } from "./models"; import { validateModelScenes } from "./models";
import { validateProxyRoleHeader } from "./proxy"; import { validateProxyRoleHeader } from "./proxy";
export type SectionValidation = ( export type SectionValidation = (
@@ -30,8 +30,7 @@ export function getSectionValidation({
} }
if (sectionPath === "models") { if (sectionPath === "models") {
return (formData, errors) => return (formData, errors) => validateModelScenes(formData, errors, t);
validateDefaultModelExists(formData, errors, t);
} }
if (sectionPath === "proxy" && level === "global") { if (sectionPath === "proxy" && level === "global") {
@@ -1,16 +1,64 @@
import type { FormValidation } from "@rjsf/utils"; import type { FormValidation } from "@rjsf/utils";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import { isJsonObject } from "@/lib/utils"; import { isJsonObject } from "@/lib/utils";
import { DEFAULT_SCENE, getSceneLabel } from "@/utils/modelUtil";
const DEFAULT_SCENE = "all"; /** The model the backend loads for a detector when no path is configured. */
function defaultModelPath(detector: string): string | undefined {
if (detector === "cpu" || detector.endsWith("_tfl")) {
return "/cpu_model.tflite";
}
if (detector === "edgetpu") {
return "/edgetpu_model.tflite";
}
if (detector === "openvino") {
return "/openvino-model/ssdlite_mobilenet_v2.xml";
}
return undefined;
}
/** /**
* A camera that names no scene runs the model whose scene is `all`. Without one * Collapse `//`, `.`, and `..` so spellings of one file path compare equal,
* the backend rejects the config outright once a second model exists, and with * as the backend's realpath does. Symlinks and copies can only be seen by the
* a single model it silently runs every camera on whatever that model is. Both * backend, which still combines them at startup.
* are surprising, so require the default to be present.
*/ */
export function validateDefaultModelExists( function normalizeModelPath(path: string): string {
// plus://<id> and other URLs are compared as written
if (path.includes("://")) {
return path;
}
const segments: string[] = [];
for (const segment of path.split("/")) {
if (segment === "" || segment === ".") {
continue;
}
if (segment === ".." && segments.length && segments.at(-1) !== "..") {
segments.pop();
} else if (segment !== ".." || !path.startsWith("/")) {
segments.push(segment);
}
}
return `${path.startsWith("/") ? "/" : ""}${segments.join("/")}`;
}
/**
* A camera that names no scene runs the model whose scene is `default`.
* Without one the backend rejects the config outright once a second model
* exists, and with a single model it silently runs every camera on whatever
* that model is. Both are surprising, so require the default to be present.
* Scenes are free-form names, so also check that no two models share one, and
* that one model isn't split across scenes. The backend combines models that
* load the same file on the same detector, so running one model per scene only
* splits the detection work across separate detectors.
*/
export function validateModelScenes(
formData: unknown, formData: unknown,
errors: FormValidation, errors: FormValidation,
t: TFunction, t: TFunction,
@@ -19,13 +67,54 @@ export function validateDefaultModelExists(
return errors; return errors;
} }
const hasDefault = formData.some( const scenes = formData.map((model) =>
(model) => isJsonObject(model) && model.scene === DEFAULT_SCENE, isJsonObject(model) && typeof model.scene === "string" ? model.scene : "",
); );
if (!hasDefault) { if (!scenes.includes(DEFAULT_SCENE)) {
errors.addError?.(t("models.defaultRequired", { ns: "config/validation" })); errors.addError?.(t("models.defaultRequired", { ns: "config/validation" }));
} }
if (new Set(scenes).size !== scenes.length) {
errors.addError?.(t("models.sceneDuplicate", { ns: "config/validation" }));
}
// detector and path -> the scene of the first model using them
const seen = new Map<string, string>();
formData.forEach((model, index) => {
if (!isJsonObject(model)) {
return;
}
const devices = Array.isArray(model.devices) ? model.devices : [];
const detector =
typeof devices[0] === "string" ? devices[0].split(":")[0] : "";
const path =
typeof model.path === "string" && model.path
? normalizeModelPath(model.path)
: defaultModelPath(detector);
if (!path) {
return;
}
const key = `${detector}|${path}`;
const other = seen.get(key);
if (other === undefined) {
seen.set(key, scenes[index]);
return;
}
errors.addError?.(
t("models.sameModel", {
ns: "config/validation",
scene: getSceneLabel(t, other),
other: getSceneLabel(t, scenes[index]),
}),
);
});
return errors; return errors;
} }
@@ -12,6 +12,7 @@ import { applySchemaDefaults } from "@/lib/config-schema";
import { isJsonObject } from "@/lib/utils"; import { isJsonObject } from "@/lib/utils";
import { HiddenFieldContext, JsonObject, JsonValue } from "@/types/configForm"; import { HiddenFieldContext, JsonObject, JsonValue } from "@/types/configForm";
import { getEffectiveAttributeLabels } from "@/utils/configUtil"; import { getEffectiveAttributeLabels } from "@/utils/configUtil";
import { getSceneChoices } from "@/utils/modelUtil";
/** /**
* Sections that require special handling at the global level. * Sections that require special handling at the global level.
@@ -38,6 +39,8 @@ export function isSpecialCaseSection(
* Modify schema for sections that need defaults stripped or other modifications. * Modify schema for sections that need defaults stripped or other modifications.
* *
* - genai: Inject a default provider value on the additionalProperties shape. * - genai: Inject a default provider value on the additionalProperties shape.
* - detect: Scenes are free-form names, so offer the configured model scenes
* (and a saved scene no model uses) as the choices for `scene`.
* - objects: Promote tracked attribute labels (face, license_plate, courier * - objects: Promote tracked attribute labels (face, license_plate, courier
* logos) from `filters.additionalProperties` to explicit * logos) from `filters.additionalProperties` to explicit
* `filters.properties.<attr>` entries with a restricted FilterConfig * `filters.properties.<attr>` entries with a restricted FilterConfig
@@ -58,6 +61,10 @@ export function modifySchemaForSection(
return modifyObjectsSchema(schema, ctx); return modifyObjectsSchema(schema, ctx);
} }
if (sectionPath === "detect") {
return modifyDetectSchema(schema, ctx);
}
if (!isSpecialCaseSection(sectionPath, level)) { if (!isSpecialCaseSection(sectionPath, level)) {
return schema; return schema;
} }
@@ -88,6 +95,24 @@ export function modifySchemaForSection(
return schema; return schema;
} }
function modifyDetectSchema(
schema: RJSFSchema,
ctx: HiddenFieldContext | undefined,
): RJSFSchema {
const properties = (schema as { properties?: Record<string, RJSFSchema> })
.properties;
if (!ctx || !properties?.scene) return schema;
return {
...schema,
properties: {
...properties,
scene: { ...properties.scene, enum: getSceneChoices(ctx) },
},
};
}
/** /**
* Build a stripped FilterConfig schema for tracked attribute filters * Build a stripped FilterConfig schema for tracked attribute filters
* (face, license_plate, etc.). Keeps only the fields meaningful for * (face, license_plate, etc.). Keeps only the fields meaningful for
@@ -22,14 +22,8 @@ import {
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from "@/components/ui/collapsible"; } from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -39,6 +33,7 @@ import type { ConfigFormContext } from "@/types/configForm";
import useSWR from "swr"; import useSWR from "swr";
import { DetectionHardware } from "@/types/hardware"; import { DetectionHardware } from "@/types/hardware";
import { summarizeDevices } from "@/utils/detectionHardware"; import { summarizeDevices } from "@/utils/detectionHardware";
import { DEFAULT_SCENE, getSceneLabel } from "@/utils/modelUtil";
import { HardwarePicker } from "./HardwarePicker"; import { HardwarePicker } from "./HardwarePicker";
import { ModelSourcePicker } from "./ModelSourcePicker"; import { ModelSourcePicker } from "./ModelSourcePicker";
@@ -103,16 +98,6 @@ const getItemProperties = (
return schema.properties as Record<string, RJSFSchema>; return schema.properties as Record<string, RJSFSchema>;
}; };
const getSceneOptions = (itemSchema: RJSFSchema | undefined): string[] => {
const scene = getItemProperties(itemSchema).scene as
Record<string, unknown> | undefined;
const values = scene?.enum;
return Array.isArray(values)
? values.filter((v): v is string => typeof v === "string")
: [];
};
export function ModelsField(props: FieldProps) { export function ModelsField(props: FieldProps) {
const { const {
schema, schema,
@@ -147,10 +132,15 @@ export function ModelsField(props: FieldProps) {
((uiSchema as { items?: UiSchema } | undefined)?.items ?? {}) as UiSchema, ((uiSchema as { items?: UiSchema } | undefined)?.items ?? {}) as UiSchema,
[uiSchema], [uiSchema],
); );
const sceneOptions = useMemo(() => getSceneOptions(itemSchema), [itemSchema]);
const SchemaField = registry.fields.SchemaField; const SchemaField = registry.fields.SchemaField;
const [openByIndex, setOpenByIndex] = useState<Record<number, boolean>>({}); const [openByIndex, setOpenByIndex] = useState<Record<number, boolean>>({});
// scenes are edited in place, so cards need a key that survives a rename and
// doesn't hand a deleted card's state (such as the model source tab) to the
// next one
const [cardKeys, setCardKeys] = useState<number[]>(() =>
models.map((_, index) => index),
);
// shared with HardwarePicker through the SWR cache, so this is not a second // shared with HardwarePicker through the SWR cache, so this is not a second
// request // request
@@ -164,34 +154,53 @@ export function ModelsField(props: FieldProps) {
} }
return next; return next;
}); });
setCardKeys((previous) => {
if (previous.length === models.length) {
return previous;
}
const next = previous.slice(0, models.length);
let key = Math.max(-1, ...previous);
while (next.length < models.length) {
key += 1;
next.push(key);
}
return next;
});
}, [models.length]); }, [models.length]);
const cameras = formContext?.fullConfig?.cameras; const cameras = formContext?.fullConfig?.cameras;
const savedModels = formContext?.fullConfig?.models; const savedModels = formContext?.fullConfig?.models;
// `plus` is a readonly field stripped from the form data, so read it from the // `plus` is a readonly field stripped from the form data, so read it from the
// full config. Match on scene rather than index, which shifts when a model is // full config. Match on path rather than index, which shifts when a model is
// added or removed. // added or removed, or scene, which can be renamed.
const savedPlusForScene = useCallback( const savedPlusForPath = useCallback(
(scene: string | undefined) => (path: unknown) =>
savedModels?.find((saved) => saved.scene === scene)?.plus, typeof path === "string"
? savedModels?.find((saved) => saved.path === path)?.plus
: undefined,
[savedModels], [savedModels],
); );
// a model serves the cameras naming its scene, and like the backend, the // a model serves the cameras naming its scene, and like the backend, the
// "all" model also serves every camera whose scene has no model of its own // default model also serves every camera whose scene has no model of its own
const cameraCountForScene = useCallback( const cameraCountForScene = useCallback(
(scene: string | undefined): number => { (scene: string | undefined): number => {
if (!cameras) { if (!cameras) {
return 0; return 0;
} }
const modelScenes = new Set(models.map((model) => model.scene ?? "all")); const modelScenes = new Set(
models.map((model) => model.scene ?? DEFAULT_SCENE),
);
return Object.values(cameras).filter((camera) => { return Object.values(cameras).filter((camera) => {
const cameraScene = camera?.detect?.scene ?? "all"; const cameraScene = camera?.detect?.scene ?? DEFAULT_SCENE;
const servedBy = modelScenes.has(cameraScene) ? cameraScene : "all"; const servedBy = modelScenes.has(cameraScene)
return servedBy === (scene ?? "all"); ? cameraScene
: DEFAULT_SCENE;
return servedBy === (scene ?? DEFAULT_SCENE);
}).length; }).length;
}, },
[cameras, models], [cameras, models],
@@ -207,13 +216,15 @@ export function ModelsField(props: FieldProps) {
} }
(model.devices ?? []).forEach((device) => { (model.devices ?? []).forEach((device) => {
claimed[device] = model.scene ?? String(currentIndex + 1); claimed[device] = model.scene
? getSceneLabel(t, model.scene)
: String(currentIndex + 1);
}); });
}); });
return claimed; return claimed;
}, },
[models], [models, t],
); );
const updateModel = useCallback( const updateModel = useCallback(
@@ -229,12 +240,15 @@ export function ModelsField(props: FieldProps) {
const base = itemSchema const base = itemSchema
? (applySchemaDefaults(itemSchema) as DetectionModel) ? (applySchemaDefaults(itemSchema) as DetectionModel)
: ({} as DetectionModel); : ({} as DetectionModel);
const taken = new Set(models.map((model) => model.scene)); // the default scene is almost always taken, so leave the new model's
const scene = sceneOptions.find((option) => !taken.has(option)); // scene for the user to name
onChange(
onChange([...models, { ...base, scene, devices: [] }], fieldPathId.path); [...models, { ...base, scene: "", devices: [] }],
fieldPathId.path,
);
setOpenByIndex((previous) => ({ ...previous, [models.length]: true })); setOpenByIndex((previous) => ({ ...previous, [models.length]: true }));
}, [models, itemSchema, sceneOptions, onChange, fieldPathId.path]); setCardKeys((previous) => [...previous, Math.max(-1, ...previous) + 1]);
}, [models, itemSchema, onChange, fieldPathId.path]);
const handleRemoveModel = useCallback( const handleRemoveModel = useCallback(
(index: number) => { (index: number) => {
@@ -254,6 +268,9 @@ export function ModelsField(props: FieldProps) {
}); });
return next; return next;
}); });
setCardKeys((previous) =>
previous.filter((_, currentIndex) => currentIndex !== index),
);
}, },
[models, onChange, fieldPathId.path], [models, onChange, fieldPathId.path],
); );
@@ -319,16 +336,16 @@ export function ModelsField(props: FieldProps) {
<div className="space-y-4"> <div className="space-y-4">
{models.map((model, index) => { {models.map((model, index) => {
const open = openByIndex[index] ?? true; const open = openByIndex[index] ?? true;
const takenScenes = new Set( const sceneErrors = (
models (errorSchema as Record<string, ErrorSchema> | undefined)?.[index] as
.filter((_, currentIndex) => currentIndex !== index) Record<string, ErrorSchema> | undefined
.map((other) => other.scene), )?.scene?.__errors;
);
return ( return (
// keyed by scene, which is unique per model, so deleting a card <Card
// doesn't hand its state (such as the model source tab) to the next key={`${baseId}-${cardKeys[index] ?? index}`}
<Card key={`${baseId}-${model.scene ?? index}`} className="w-full"> className="w-full"
>
<Collapsible <Collapsible
open={open} open={open}
onOpenChange={(nextOpen) => onOpenChange={(nextOpen) =>
@@ -342,9 +359,7 @@ export function ModelsField(props: FieldProps) {
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<CardTitle className="flex-1 cursor-pointer text-sm"> <CardTitle className="flex-1 cursor-pointer text-sm">
<span> <span>{getSceneLabel(t, model.scene)}</span>
{t(`detectionModels.scenes.${model.scene ?? "all"}`)}
</span>
<span className="mt-1 block text-xs font-normal text-muted-foreground"> <span className="mt-1 block text-xs font-normal text-muted-foreground">
{summarizeDevices( {summarizeDevices(
hardware ?? [], hardware ?? [],
@@ -401,27 +416,24 @@ export function ModelsField(props: FieldProps) {
<CollapsibleContent> <CollapsibleContent>
<CardContent className="space-y-6 p-4 pt-0"> <CardContent className="space-y-6 p-4 pt-0">
<div className="space-y-1"> <div className="space-y-1">
<Label>{t("detectionModels.scene.label")}</Label> <Label htmlFor={`${baseId}-${index}-scene`}>
<Select {t("detectionModels.scene.label")}
</Label>
<Input
id={`${baseId}-${index}-scene`}
className="max-w-xs"
value={model.scene ?? ""} value={model.scene ?? ""}
onValueChange={(scene) => updateModel(index, { scene })} placeholder={t("detectionModels.scene.placeholder")}
onChange={(event) =>
updateModel(index, { scene: event.target.value.trim() })
}
disabled={disabled || readonly} disabled={disabled || readonly}
> />
<SelectTrigger className="max-w-xs"> {!hideError && sceneErrors?.length ? (
<SelectValue /> <p className="text-xs text-destructive">
</SelectTrigger> {sceneErrors.join(", ")}
<SelectContent> </p>
{sceneOptions.map((scene) => ( ) : null}
<SelectItem
key={scene}
value={scene}
disabled={takenScenes.has(scene)}
>
{t(`detectionModels.scenes.${scene}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t("detectionModels.scene.description")} {t("detectionModels.scene.description")}
</p> </p>
@@ -438,7 +450,7 @@ export function ModelsField(props: FieldProps) {
<ModelSourcePicker <ModelSourcePicker
path={model.path} path={model.path}
plus={savedPlusForScene(model.scene)} plus={savedPlusForPath(model.path)}
detector={detectorForModel(model)} detector={detectorForModel(model)}
disabled={disabled || readonly} disabled={disabled || readonly}
onPathChange={(path) => updateModel(index, { path })} onPathChange={(path) => updateModel(index, { path })}
@@ -453,19 +465,17 @@ export function ModelsField(props: FieldProps) {
); );
})} })}
{models.length < sceneOptions.length ? ( <Button
<Button type="button"
type="button" variant="outline"
variant="outline" size="sm"
size="sm" onClick={handleAddModel}
onClick={handleAddModel} disabled={disabled || readonly}
disabled={disabled || readonly} className="gap-2"
className="gap-2" >
> <LuPlus className="h-4 w-4" />
<LuPlus className="h-4 w-4" /> {t("detectionModels.addModel")}
{t("detectionModels.addModel")} </Button>
</Button>
) : null}
</div> </div>
); );
} }
@@ -22,14 +22,23 @@ export function SelectWidget(props: WidgetProps) {
schema, schema,
} = props; } = props;
const { t } = useTranslation(["views/settings"]); const { t, i18n } = useTranslation(["views/settings"]);
const { enumOptions = [] } = options; const { enumOptions = [] } = options;
const enumI18nPrefix = options["enumI18nPrefix"] as string | undefined; const enumI18nPrefix = options["enumI18nPrefix"] as string | undefined;
const enumI18nOptional = options["enumI18nOptional"] === true;
const fieldClassName = getSizedFieldClassName(options, "sm"); const fieldClassName = getSizedFieldClassName(options, "sm");
const getLabel = (option: { value: unknown; label: string }) => { const getLabel = (option: { value: unknown; label: string }) => {
if (enumI18nPrefix) { if (enumI18nPrefix) {
return t(`${enumI18nPrefix}.${option.value}`); const key = `${enumI18nPrefix}.${option.value}`;
// user-defined values (such as model scenes) are shown as named rather
// than humanized by the missing key handler
if (enumI18nOptional && !i18n.exists(key, { ns: "views/settings" })) {
return String(option.value);
}
return t(key);
} }
return option.label; return option.label;
}; };
+1
View File
@@ -490,6 +490,7 @@ export interface FrigateConfig {
height: number | null; height: number | null;
max_disappeared: number | null; max_disappeared: number | null;
min_initialized: number | null; min_initialized: number | null;
scene: string;
stationary: { stationary: {
interval: number | null; interval: number | null;
max_frames: { max_frames: {
+2 -4
View File
@@ -10,6 +10,7 @@ import type { FrigateConfig } from "@/types/frigateConfig";
import type { HealthProblem } from "@/types/health"; import type { HealthProblem } from "@/types/health";
import { getSectionConfig } from "@/utils/configUtil"; import { getSectionConfig } from "@/utils/configUtil";
import { activeCameras } from "@/utils/health"; import { activeCameras } from "@/utils/health";
import { getSceneLabel } from "@/utils/modelUtil";
import { resolveMessageKey } from "@/utils/runtimeOverrides"; import { resolveMessageKey } from "@/utils/runtimeOverrides";
function healthMessages( function healthMessages(
@@ -119,10 +120,7 @@ export function evaluateConfigHealth(
section === "models" && Array.isArray(sectionData) section === "models" && Array.isArray(sectionData)
? sectionData.map((model, index) => ({ ? sectionData.map((model, index) => ({
formData: model as ConfigSectionData, formData: model as ConfigSectionData,
scope: t( scope: getSceneLabel(t, (model as { scene?: string }).scene),
`detectionModels.scenes.${(model as { scene?: string }).scene || "all"}`,
{ ns: "views/settings" },
),
idSuffix: `model${index}`, idSuffix: `model${index}`,
})) }))
: [ : [
+2 -3
View File
@@ -11,6 +11,7 @@ import type {
import type { EmbeddingsStats, FrigateStats, GpuVendor } from "@/types/stats"; import type { EmbeddingsStats, FrigateStats, GpuVendor } from "@/types/stats";
import { EmbeddingThreshold, InferenceThreshold } from "@/types/graph"; import { EmbeddingThreshold, InferenceThreshold } from "@/types/graph";
import { summarizeDevices } from "@/utils/detectionHardware"; import { summarizeDevices } from "@/utils/detectionHardware";
import { getSceneLabel } from "@/utils/modelUtil";
import { isReplayCamera } from "@/utils/cameraUtil"; import { isReplayCamera } from "@/utils/cameraUtil";
import { resolveCameraName } from "@/hooks/use-camera-friendly-name"; import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
@@ -142,9 +143,7 @@ export function detectionRows({
const modelRunners = names.slice(cursor, cursor + model.devices.length); const modelRunners = names.slice(cursor, cursor + model.devices.length);
cursor += model.devices.length; cursor += model.devices.length;
const label = t(`detectionModels.scenes.${model.scene || "all"}`, { const label = getSceneLabel(t, model.scene);
ns: "views/settings",
});
const id = `detection:${index}`; const id = `detection:${index}`;
const detail = probeFailed const detail = probeFailed
? t("health.hardware.probeUnavailable", { ? t("health.hardware.probeUnavailable", {
+42 -3
View File
@@ -1,10 +1,49 @@
import type { TFunction } from "i18next";
import type { HiddenFieldContext } from "@/types/configForm";
import { DetectionModelConfig, FrigateConfig } from "@/types/frigateConfig"; import { DetectionModelConfig, FrigateConfig } from "@/types/frigateConfig";
/** The scene of the model used by cameras that don't name one. */
export const DEFAULT_SCENE = "default";
/** Display name for a scene; custom scenes are shown as the user named them. */
export function getSceneLabel(t: TFunction, scene: string | undefined): string {
if (!scene || scene === DEFAULT_SCENE) {
return t("detectionModels.scenes.default", { ns: "views/settings" });
}
return scene;
}
/**
* The scenes a detect section can choose from: those of the configured models,
* default first when a default model exists, plus the saved scene when no model
* uses it, so it can still be seen and changed.
*/
export function getSceneChoices(
ctx: Pick<HiddenFieldContext, "fullConfig" | "fullCameraConfig" | "level">,
): string[] {
const scenes = [
...new Set(
ctx.fullConfig.models?.map((model) => model.scene || DEFAULT_SCENE),
),
].sort((a, b) => Number(b === DEFAULT_SCENE) - Number(a === DEFAULT_SCENE));
const saved =
(ctx.level !== "global"
? ctx.fullCameraConfig?.detect?.scene
: undefined) ?? ctx.fullConfig.detect?.scene;
if (saved && !scenes.includes(saved)) {
scenes.push(saved);
}
return scenes;
}
/** /**
* The model a camera runs on, matched by the camera's detect scene. * The model a camera runs on, matched by the camera's detect scene.
* *
* Falls back to the model for every scene, then to the only configured model, * Falls back to the default model, then to the only configured model, which
* which is what the backend does when a camera does not name a scene. * is what the backend does when a camera does not name a scene.
*/ */
export function getModelForCamera( export function getModelForCamera(
config?: FrigateConfig, config?: FrigateConfig,
@@ -26,7 +65,7 @@ export function getModelForCamera(
} }
} }
return models.find((model) => model.scene == "all") ?? models[0]; return models.find((model) => model.scene == DEFAULT_SCENE) ?? models[0];
} }
/** The model used when the question is not about a specific camera. */ /** The model used when the question is not about a specific camera. */