mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-07 15:22:48 +03:00
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
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:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}`,
|
||||
}))
|
||||
: [
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user