Refactor model scene definitions (#24508)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s

* Refactor model scene definitions

* Cleanup

* Validate model paths in the UI

* Handle form validation
This commit is contained in:
Nicolas Mowen
2026-09-30 06:02:14 -06:00
committed by GitHub
parent 5e87d101da
commit 1bb61eb808
30 changed files with 862 additions and 227 deletions
@@ -1,5 +1,12 @@
import type { HiddenFieldContext } from "@/types/configForm";
import { DEFAULT_SCENE, getSceneChoices } from "@/utils/modelUtil";
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 = {
base: {
sectionDocs: "/configuration/camera_specific",
@@ -123,7 +130,7 @@ const detect: SectionConfigOverrides = {
docLink: "/configuration/object_detectors#running-more-than-one-model",
condition: (ctx) => {
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;
if (!models) return false;
return !models.some((model) => model.scene === scene);
@@ -194,6 +201,7 @@ const detect: SectionConfigOverrides = {
scene: {
"ui:options": {
enumI18nPrefix: "detectionModels.scenes",
enumI18nOptional: true,
},
},
annotation_offset: {
@@ -202,7 +210,7 @@ const detect: SectionConfigOverrides = {
},
},
},
hiddenFields: ["enabled_in_config"],
hiddenFields: ["enabled_in_config", hideSceneWithoutChoice],
advancedFields: [
"min_initialized",
"max_disappeared",
@@ -2,7 +2,7 @@ import type { FormValidation } from "@rjsf/utils";
import type { TFunction } from "i18next";
import { validateDetectDimensions } from "./detect";
import { validateFfmpegInputRoles } from "./ffmpeg";
import { validateDefaultModelExists } from "./models";
import { validateModelScenes } from "./models";
import { validateProxyRoleHeader } from "./proxy";
export type SectionValidation = (
@@ -30,8 +30,7 @@ export function getSectionValidation({
}
if (sectionPath === "models") {
return (formData, errors) =>
validateDefaultModelExists(formData, errors, t);
return (formData, errors) => validateModelScenes(formData, errors, t);
}
if (sectionPath === "proxy" && level === "global") {
@@ -1,16 +1,64 @@
import type { FormValidation } from "@rjsf/utils";
import type { TFunction } from "i18next";
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
* 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.
* Collapse `//`, `.`, and `..` so spellings of one file path compare equal,
* as the backend's realpath does. Symlinks and copies can only be seen by the
* backend, which still combines them at startup.
*/
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,
errors: FormValidation,
t: TFunction,
@@ -19,13 +67,54 @@ export function validateDefaultModelExists(
return errors;
}
const hasDefault = formData.some(
(model) => isJsonObject(model) && model.scene === DEFAULT_SCENE,
const scenes = formData.map((model) =>
isJsonObject(model) && typeof model.scene === "string" ? model.scene : "",
);
if (!hasDefault) {
if (!scenes.includes(DEFAULT_SCENE)) {
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;
}
@@ -12,6 +12,7 @@ import { applySchemaDefaults } from "@/lib/config-schema";
import { isJsonObject } from "@/lib/utils";
import { HiddenFieldContext, JsonObject, JsonValue } from "@/types/configForm";
import { getEffectiveAttributeLabels } from "@/utils/configUtil";
import { getSceneChoices } from "@/utils/modelUtil";
/**
* 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.
*
* - 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
* logos) from `filters.additionalProperties` to explicit
* `filters.properties.<attr>` entries with a restricted FilterConfig
@@ -58,6 +61,10 @@ export function modifySchemaForSection(
return modifyObjectsSchema(schema, ctx);
}
if (sectionPath === "detect") {
return modifyDetectSchema(schema, ctx);
}
if (!isSpecialCaseSection(sectionPath, level)) {
return schema;
}
@@ -88,6 +95,24 @@ export function modifySchemaForSection(
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
* (face, license_plate, etc.). Keeps only the fields meaningful for
@@ -22,14 +22,8 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
@@ -39,6 +33,7 @@ import type { ConfigFormContext } from "@/types/configForm";
import useSWR from "swr";
import { DetectionHardware } from "@/types/hardware";
import { summarizeDevices } from "@/utils/detectionHardware";
import { DEFAULT_SCENE, getSceneLabel } from "@/utils/modelUtil";
import { HardwarePicker } from "./HardwarePicker";
import { ModelSourcePicker } from "./ModelSourcePicker";
@@ -103,16 +98,6 @@ const getItemProperties = (
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) {
const {
schema,
@@ -147,10 +132,15 @@ export function ModelsField(props: FieldProps) {
((uiSchema as { items?: UiSchema } | undefined)?.items ?? {}) as UiSchema,
[uiSchema],
);
const sceneOptions = useMemo(() => getSceneOptions(itemSchema), [itemSchema]);
const SchemaField = registry.fields.SchemaField;
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
// request
@@ -164,34 +154,53 @@ export function ModelsField(props: FieldProps) {
}
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]);
const cameras = formContext?.fullConfig?.cameras;
const savedModels = formContext?.fullConfig?.models;
// `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
// added or removed.
const savedPlusForScene = useCallback(
(scene: string | undefined) =>
savedModels?.find((saved) => saved.scene === scene)?.plus,
// full config. Match on path rather than index, which shifts when a model is
// added or removed, or scene, which can be renamed.
const savedPlusForPath = useCallback(
(path: unknown) =>
typeof path === "string"
? savedModels?.find((saved) => saved.path === path)?.plus
: undefined,
[savedModels],
);
// 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(
(scene: string | undefined): number => {
if (!cameras) {
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) => {
const cameraScene = camera?.detect?.scene ?? "all";
const servedBy = modelScenes.has(cameraScene) ? cameraScene : "all";
return servedBy === (scene ?? "all");
const cameraScene = camera?.detect?.scene ?? DEFAULT_SCENE;
const servedBy = modelScenes.has(cameraScene)
? cameraScene
: DEFAULT_SCENE;
return servedBy === (scene ?? DEFAULT_SCENE);
}).length;
},
[cameras, models],
@@ -207,13 +216,15 @@ export function ModelsField(props: FieldProps) {
}
(model.devices ?? []).forEach((device) => {
claimed[device] = model.scene ?? String(currentIndex + 1);
claimed[device] = model.scene
? getSceneLabel(t, model.scene)
: String(currentIndex + 1);
});
});
return claimed;
},
[models],
[models, t],
);
const updateModel = useCallback(
@@ -229,12 +240,15 @@ export function ModelsField(props: FieldProps) {
const base = itemSchema
? (applySchemaDefaults(itemSchema) as DetectionModel)
: ({} as DetectionModel);
const taken = new Set(models.map((model) => model.scene));
const scene = sceneOptions.find((option) => !taken.has(option));
onChange([...models, { ...base, scene, devices: [] }], fieldPathId.path);
// the default scene is almost always taken, so leave the new model's
// scene for the user to name
onChange(
[...models, { ...base, scene: "", devices: [] }],
fieldPathId.path,
);
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(
(index: number) => {
@@ -254,6 +268,9 @@ export function ModelsField(props: FieldProps) {
});
return next;
});
setCardKeys((previous) =>
previous.filter((_, currentIndex) => currentIndex !== index),
);
},
[models, onChange, fieldPathId.path],
);
@@ -319,16 +336,16 @@ export function ModelsField(props: FieldProps) {
<div className="space-y-4">
{models.map((model, index) => {
const open = openByIndex[index] ?? true;
const takenScenes = new Set(
models
.filter((_, currentIndex) => currentIndex !== index)
.map((other) => other.scene),
);
const sceneErrors = (
(errorSchema as Record<string, ErrorSchema> | undefined)?.[index] as
Record<string, ErrorSchema> | undefined
)?.scene?.__errors;
return (
// keyed by scene, which is unique per model, so deleting a card
// doesn't hand its state (such as the model source tab) to the next
<Card key={`${baseId}-${model.scene ?? index}`} className="w-full">
<Card
key={`${baseId}-${cardKeys[index] ?? index}`}
className="w-full"
>
<Collapsible
open={open}
onOpenChange={(nextOpen) =>
@@ -342,9 +359,7 @@ export function ModelsField(props: FieldProps) {
<div className="flex items-center justify-between gap-4">
<CollapsibleTrigger asChild>
<CardTitle className="flex-1 cursor-pointer text-sm">
<span>
{t(`detectionModels.scenes.${model.scene ?? "all"}`)}
</span>
<span>{getSceneLabel(t, model.scene)}</span>
<span className="mt-1 block text-xs font-normal text-muted-foreground">
{summarizeDevices(
hardware ?? [],
@@ -401,27 +416,24 @@ export function ModelsField(props: FieldProps) {
<CollapsibleContent>
<CardContent className="space-y-6 p-4 pt-0">
<div className="space-y-1">
<Label>{t("detectionModels.scene.label")}</Label>
<Select
<Label htmlFor={`${baseId}-${index}-scene`}>
{t("detectionModels.scene.label")}
</Label>
<Input
id={`${baseId}-${index}-scene`}
className="max-w-xs"
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}
>
<SelectTrigger className="max-w-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{sceneOptions.map((scene) => (
<SelectItem
key={scene}
value={scene}
disabled={takenScenes.has(scene)}
>
{t(`detectionModels.scenes.${scene}`)}
</SelectItem>
))}
</SelectContent>
</Select>
/>
{!hideError && sceneErrors?.length ? (
<p className="text-xs text-destructive">
{sceneErrors.join(", ")}
</p>
) : null}
<p className="text-xs text-muted-foreground">
{t("detectionModels.scene.description")}
</p>
@@ -438,7 +450,7 @@ export function ModelsField(props: FieldProps) {
<ModelSourcePicker
path={model.path}
plus={savedPlusForScene(model.scene)}
plus={savedPlusForPath(model.path)}
detector={detectorForModel(model)}
disabled={disabled || readonly}
onPathChange={(path) => updateModel(index, { path })}
@@ -453,19 +465,17 @@ export function ModelsField(props: FieldProps) {
);
})}
{models.length < sceneOptions.length ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddModel}
disabled={disabled || readonly}
className="gap-2"
>
<LuPlus className="h-4 w-4" />
{t("detectionModels.addModel")}
</Button>
) : null}
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddModel}
disabled={disabled || readonly}
className="gap-2"
>
<LuPlus className="h-4 w-4" />
{t("detectionModels.addModel")}
</Button>
</div>
);
}
@@ -22,14 +22,23 @@ export function SelectWidget(props: WidgetProps) {
schema,
} = props;
const { t } = useTranslation(["views/settings"]);
const { t, i18n } = useTranslation(["views/settings"]);
const { enumOptions = [] } = options;
const enumI18nPrefix = options["enumI18nPrefix"] as string | undefined;
const enumI18nOptional = options["enumI18nOptional"] === true;
const fieldClassName = getSizedFieldClassName(options, "sm");
const getLabel = (option: { value: unknown; label: string }) => {
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;
};
+1
View File
@@ -490,6 +490,7 @@ export interface FrigateConfig {
height: number | null;
max_disappeared: number | null;
min_initialized: number | null;
scene: string;
stationary: {
interval: number | null;
max_frames: {
+2 -4
View File
@@ -10,6 +10,7 @@ import type { FrigateConfig } from "@/types/frigateConfig";
import type { HealthProblem } from "@/types/health";
import { getSectionConfig } from "@/utils/configUtil";
import { activeCameras } from "@/utils/health";
import { getSceneLabel } from "@/utils/modelUtil";
import { resolveMessageKey } from "@/utils/runtimeOverrides";
function healthMessages(
@@ -119,10 +120,7 @@ export function evaluateConfigHealth(
section === "models" && Array.isArray(sectionData)
? sectionData.map((model, index) => ({
formData: model as ConfigSectionData,
scope: t(
`detectionModels.scenes.${(model as { scene?: string }).scene || "all"}`,
{ ns: "views/settings" },
),
scope: getSceneLabel(t, (model as { scene?: string }).scene),
idSuffix: `model${index}`,
}))
: [
+2 -3
View File
@@ -11,6 +11,7 @@ import type {
import type { EmbeddingsStats, FrigateStats, GpuVendor } from "@/types/stats";
import { EmbeddingThreshold, InferenceThreshold } from "@/types/graph";
import { summarizeDevices } from "@/utils/detectionHardware";
import { getSceneLabel } from "@/utils/modelUtil";
import { isReplayCamera } from "@/utils/cameraUtil";
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
@@ -142,9 +143,7 @@ export function detectionRows({
const modelRunners = names.slice(cursor, cursor + model.devices.length);
cursor += model.devices.length;
const label = t(`detectionModels.scenes.${model.scene || "all"}`, {
ns: "views/settings",
});
const label = getSceneLabel(t, model.scene);
const id = `detection:${index}`;
const detail = probeFailed
? 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";
/** 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.
*
* Falls back to the model for every scene, then to the only configured model,
* which is what the backend does when a camera does not name a scene.
* Falls back to the default model, then to the only configured model, which
* is what the backend does when a camera does not name a scene.
*/
export function getModelForCamera(
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. */