mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-24 12:49:01 +03:00
Add attributes to UI filters list (#23250)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* preserve user-set min_score on attribute filters instead of bumping any 0.5 value use model_fields_set to distinguish "user explicitly set min_score" from "Pydantic applied the generic FilterConfig default of 0.5" * add config test for attributes * fix attributes frontend type * add expanded hidden field context * extend schema modification * special case for attributes * i18n for attributes * handle dedicated lpr mode * strip unrendered FilterConfig fields from attribute filter form data to fix validation errors
This commit is contained in:
@@ -1,12 +1,60 @@
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import type { HiddenFieldContext } from "@/types/configForm";
|
||||
import { getEffectiveAttributeLabels } from "@/utils/configUtil";
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
// Attribute labels (face, license_plate, Frigate+ couriers like DHL/Amazon,
|
||||
// etc.) are populated into objects.filters by the backend even when the
|
||||
// model can't actually detect them. They aren't user-settable, so hide any
|
||||
// `filters.<attr>` patterns from forms and override comparisons.
|
||||
const hideAttributeFilters = (config: FrigateConfig): string[] =>
|
||||
(config.model?.all_attributes ?? []).map((attr) => `filters.${attr}`);
|
||||
// etc.) are populated into objects.filters by the backend for every
|
||||
// attribute the model knows about.
|
||||
//
|
||||
// - Untracked attributes: hide the whole `filters.<attr>` collapsible.
|
||||
// - Tracked attributes: strip the FilterConfig fields we don't expose
|
||||
// (`threshold`, `min_ratio`, `max_ratio`) from the form data so RJSF
|
||||
// doesn't surface them as ad-hoc additionalProperties entries under the
|
||||
// restricted AttributeFilter schema (see modifySchemaForSection objects
|
||||
// branch). The data is sanitized out symmetrically from the baseline
|
||||
// too, so power-user YAML values for those fields are preserved on save
|
||||
// (buildOverrides only emits diffs of fields the form has seen).
|
||||
const ATTRIBUTE_FILTER_HIDDEN_SUBFIELDS = [
|
||||
"threshold",
|
||||
"min_ratio",
|
||||
"max_ratio",
|
||||
];
|
||||
|
||||
const hideAttributeFilters = ({
|
||||
fullConfig,
|
||||
fullCameraConfig,
|
||||
level,
|
||||
formData,
|
||||
}: HiddenFieldContext): string[] => {
|
||||
const trackFromForm = Array.isArray(
|
||||
(formData as { track?: unknown } | undefined)?.track,
|
||||
)
|
||||
? (formData as { track: string[] }).track
|
||||
: undefined;
|
||||
|
||||
const track =
|
||||
trackFromForm ??
|
||||
(level !== "global" ? fullCameraConfig?.objects?.track : undefined) ??
|
||||
fullConfig.objects?.track ??
|
||||
[];
|
||||
|
||||
const attrs = getEffectiveAttributeLabels(
|
||||
fullConfig,
|
||||
fullCameraConfig,
|
||||
level,
|
||||
);
|
||||
const hidden: string[] = [];
|
||||
for (const attr of attrs) {
|
||||
if (!track.includes(attr)) {
|
||||
hidden.push(`filters.${attr}`);
|
||||
} else {
|
||||
for (const field of ATTRIBUTE_FILTER_HIDDEN_SUBFIELDS) {
|
||||
hidden.push(`filters.${attr}.${field}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return hidden;
|
||||
};
|
||||
|
||||
const objects: SectionConfigOverrides = {
|
||||
base: {
|
||||
|
||||
@@ -308,11 +308,30 @@ export function ConfigSection({
|
||||
// Get section schema using cached hook
|
||||
const sectionSchema = useSectionSchema(sectionPath, effectiveLevel);
|
||||
|
||||
// Apply special case handling for sections with problematic schema defaults
|
||||
// Apply special case handling for sections with problematic schema defaults.
|
||||
// The HiddenFieldContext is built from `config` (saved state) only — not the
|
||||
// in-flight raw section value — because the schema is computed before
|
||||
// rawFormData is derived. The objects-branch fallback in
|
||||
// modifySchemaForSection reads `track` from fullCameraConfig / fullConfig.
|
||||
const modifiedSchema = useMemo(
|
||||
() =>
|
||||
modifySchemaForSection(sectionPath, level, sectionSchema ?? undefined),
|
||||
[sectionPath, level, sectionSchema],
|
||||
modifySchemaForSection(
|
||||
sectionPath,
|
||||
level,
|
||||
sectionSchema ?? undefined,
|
||||
config
|
||||
? {
|
||||
fullConfig: config,
|
||||
fullCameraConfig:
|
||||
effectiveLevel === "camera" && cameraName
|
||||
? config.cameras?.[cameraName]
|
||||
: undefined,
|
||||
level,
|
||||
cameraName,
|
||||
}
|
||||
: undefined,
|
||||
),
|
||||
[sectionPath, level, sectionSchema, config, effectiveLevel, cameraName],
|
||||
);
|
||||
|
||||
// Get override status (camera vs global)
|
||||
@@ -384,7 +403,19 @@ export function ConfigSection({
|
||||
// When editing a profile, hide fields that require a restart since they
|
||||
// cannot take effect via profile switching alone.
|
||||
const effectiveHiddenFields = useMemo(() => {
|
||||
const base = resolveHiddenFieldEntries(sectionConfig.hiddenFields, config);
|
||||
const ctx = config
|
||||
? {
|
||||
fullConfig: config,
|
||||
fullCameraConfig:
|
||||
effectiveLevel === "camera" && cameraName
|
||||
? config.cameras?.[cameraName]
|
||||
: undefined,
|
||||
level,
|
||||
cameraName,
|
||||
formData: rawFormData,
|
||||
}
|
||||
: undefined;
|
||||
const base = resolveHiddenFieldEntries(sectionConfig.hiddenFields, ctx);
|
||||
if (!profileName || !sectionConfig.restartRequired?.length) {
|
||||
return base;
|
||||
}
|
||||
@@ -394,6 +425,10 @@ export function ConfigSection({
|
||||
sectionConfig.hiddenFields,
|
||||
sectionConfig.restartRequired,
|
||||
config,
|
||||
effectiveLevel,
|
||||
cameraName,
|
||||
level,
|
||||
rawFormData,
|
||||
]);
|
||||
|
||||
const sanitizeSectionData = useCallback(
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { ProfilesApiResponse } from "@/types/profile";
|
||||
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||
import { formatList } from "@/utils/stringUtil";
|
||||
import {
|
||||
buildHiddenFieldContext,
|
||||
getEffectiveHiddenFields,
|
||||
pathMatchesHiddenPattern,
|
||||
} from "@/utils/configUtil";
|
||||
@@ -187,7 +188,7 @@ export function CameraOverridesBadge({ sectionPath, className }: Props) {
|
||||
const hiddenFields = getEffectiveHiddenFields(
|
||||
sectionPath,
|
||||
"global",
|
||||
config,
|
||||
buildHiddenFieldContext(config, "global"),
|
||||
);
|
||||
if (hiddenFields.length === 0) return rawEntries;
|
||||
return rawEntries
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
import { RJSFSchema } from "@rjsf/utils";
|
||||
import { applySchemaDefaults } from "@/lib/config-schema";
|
||||
import { isJsonObject } from "@/lib/utils";
|
||||
import { JsonObject, JsonValue } from "@/types/configForm";
|
||||
import { HiddenFieldContext, JsonObject, JsonValue } from "@/types/configForm";
|
||||
import { getEffectiveAttributeLabels } from "@/utils/configUtil";
|
||||
|
||||
/**
|
||||
* Sections that require special handling at the global level.
|
||||
@@ -37,13 +38,28 @@ export function isSpecialCaseSection(
|
||||
*
|
||||
* - detectors: Strip the "default" field to prevent RJSF from merging the
|
||||
* default {"cpu": {"type": "cpu"}} with stored detector keys.
|
||||
* - genai: Inject a default provider value on the additionalProperties shape.
|
||||
* - objects: Promote tracked attribute labels (face, license_plate, courier
|
||||
* logos) from `filters.additionalProperties` to explicit
|
||||
* `filters.properties.<attr>` entries with a restricted FilterConfig
|
||||
* shape, so RJSF renders just that one field for
|
||||
* attribute filters. Non-attribute tracked labels (person, car, …)
|
||||
* keep flowing through the unmodified `additionalProperties` and render
|
||||
* the full FilterConfig form.
|
||||
*/
|
||||
export function modifySchemaForSection(
|
||||
sectionPath: string,
|
||||
level: string,
|
||||
schema: RJSFSchema | undefined,
|
||||
ctx?: HiddenFieldContext,
|
||||
): RJSFSchema | undefined {
|
||||
if (!schema || !isSpecialCaseSection(sectionPath, level)) {
|
||||
if (!schema) return schema;
|
||||
|
||||
if (sectionPath === "objects") {
|
||||
return modifyObjectsSchema(schema, ctx);
|
||||
}
|
||||
|
||||
if (!isSpecialCaseSection(sectionPath, level)) {
|
||||
return schema;
|
||||
}
|
||||
|
||||
@@ -79,6 +95,151 @@ export function modifySchemaForSection(
|
||||
return schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stripped FilterConfig schema for tracked attribute filters
|
||||
* (face, license_plate, etc.). Keeps only the fields meaningful for
|
||||
* attribute detections — `min_score`, `min_area`, `max_area`. `threshold`
|
||||
* and the ratio fields aren't exposed: attributes don't flow through
|
||||
* `_is_false_positive` (no median-of-history check), and aspect-ratio
|
||||
* filtering isn't a typical attribute-tuning knob.
|
||||
*
|
||||
* `min_area` and `max_area` are `Union[int, float]` in Pydantic which
|
||||
* emits as `anyOf` in JSON schema; we flatten to a plain `number` so RJSF
|
||||
* doesn't render the int/float type-selector dropdown for each attribute
|
||||
* filter. The backend still accepts either int (pixels) or float
|
||||
* (percentage) since the underlying FilterConfig union is unchanged.
|
||||
*/
|
||||
function buildAttributeFilterSchema(
|
||||
filterConfigSchema: RJSFSchema,
|
||||
attributeLabel: string,
|
||||
): RJSFSchema {
|
||||
const props = isJsonObject(
|
||||
(filterConfigSchema as { properties?: unknown }).properties,
|
||||
)
|
||||
? (filterConfigSchema as { properties: Record<string, RJSFSchema> })
|
||||
.properties
|
||||
: undefined;
|
||||
|
||||
const minScoreSchema =
|
||||
props && props.min_score ? props.min_score : { type: "number" };
|
||||
|
||||
const flattenToNumber = (src: RJSFSchema | undefined): RJSFSchema => {
|
||||
if (!src) return { type: "number" };
|
||||
const { anyOf: _anyOf, ...rest } = src as {
|
||||
anyOf?: unknown;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
return { ...rest, type: "number" } as RJSFSchema;
|
||||
};
|
||||
|
||||
return {
|
||||
type: "object",
|
||||
title: attributeLabel,
|
||||
properties: {
|
||||
min_score: minScoreSchema,
|
||||
min_area: flattenToNumber(props && props.min_area),
|
||||
max_area: flattenToNumber(props && props.max_area),
|
||||
},
|
||||
additionalProperties: false,
|
||||
} as RJSFSchema;
|
||||
}
|
||||
|
||||
function modifyObjectsSchema(
|
||||
schema: RJSFSchema,
|
||||
ctx: HiddenFieldContext | undefined,
|
||||
): RJSFSchema {
|
||||
if (!ctx) return schema;
|
||||
|
||||
const allAttributes = getEffectiveAttributeLabels(
|
||||
ctx.fullConfig,
|
||||
ctx.fullCameraConfig,
|
||||
ctx.level,
|
||||
);
|
||||
|
||||
// Resolve effective track at this scope, falling back through camera
|
||||
// config then global config (matches hideAttributeFilters in objects.ts).
|
||||
const trackFromForm = Array.isArray(
|
||||
(ctx.formData as { track?: unknown } | undefined)?.track,
|
||||
)
|
||||
? (ctx.formData as { track: string[] }).track
|
||||
: undefined;
|
||||
const track =
|
||||
trackFromForm ??
|
||||
(ctx.level !== "global"
|
||||
? ctx.fullCameraConfig?.objects?.track
|
||||
: undefined) ??
|
||||
ctx.fullConfig.objects?.track ??
|
||||
[];
|
||||
|
||||
if (track.length === 0) return schema;
|
||||
|
||||
const schemaProperties = isJsonObject(
|
||||
(schema as { properties?: unknown }).properties,
|
||||
)
|
||||
? (schema as { properties: Record<string, RJSFSchema> }).properties
|
||||
: undefined;
|
||||
const filtersSchema =
|
||||
schemaProperties && schemaProperties.filters
|
||||
? schemaProperties.filters
|
||||
: undefined;
|
||||
if (!filtersSchema) return schema;
|
||||
|
||||
const filterEntrySchema = isJsonObject(
|
||||
(filtersSchema as { additionalProperties?: unknown }).additionalProperties,
|
||||
)
|
||||
? (filtersSchema as { additionalProperties: RJSFSchema })
|
||||
.additionalProperties
|
||||
: undefined;
|
||||
if (!filterEntrySchema) return schema;
|
||||
|
||||
const attributeSet = new Set(allAttributes);
|
||||
const existingProperties = isJsonObject(
|
||||
(filtersSchema as { properties?: unknown }).properties,
|
||||
)
|
||||
? (filtersSchema as { properties: Record<string, RJSFSchema> }).properties
|
||||
: {};
|
||||
|
||||
// Promote every tracked label to an explicit property entry so RJSF
|
||||
// renders it as a normal collapsible (no additionalProperties key/value
|
||||
// editor UI). Attribute labels get a restricted shape with only
|
||||
// `min_score`; non-attribute labels get the full FilterConfig. Sorted
|
||||
// alphabetically so the filter collapsibles match the order of the
|
||||
// sibling `track` switches.
|
||||
const sortedTrackedLabels = track
|
||||
.filter((label): label is string => typeof label === "string")
|
||||
.slice()
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
const updatedFilterProperties: Record<string, RJSFSchema> = {
|
||||
...existingProperties,
|
||||
};
|
||||
for (const label of sortedTrackedLabels) {
|
||||
if (attributeSet.has(label)) {
|
||||
updatedFilterProperties[label] = buildAttributeFilterSchema(
|
||||
filterEntrySchema,
|
||||
label,
|
||||
);
|
||||
} else {
|
||||
updatedFilterProperties[label] = {
|
||||
...filterEntrySchema,
|
||||
title: label,
|
||||
} as RJSFSchema;
|
||||
}
|
||||
}
|
||||
|
||||
const updatedFiltersSchema: RJSFSchema = {
|
||||
...filtersSchema,
|
||||
properties: updatedFilterProperties,
|
||||
};
|
||||
|
||||
return {
|
||||
...schema,
|
||||
properties: {
|
||||
...schemaProperties,
|
||||
filters: updatedFiltersSchema,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get effective defaults for sections with special schema patterns.
|
||||
*
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import type { ConfigFormContext } from "@/types/configForm";
|
||||
import { getEffectiveAttributeLabels } from "@/utils/configUtil";
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null;
|
||||
@@ -70,12 +71,27 @@ export function buildTranslationPath(
|
||||
(segment): segment is string => typeof segment === "string",
|
||||
);
|
||||
|
||||
// Handle filters section - skip the dynamic filter object name
|
||||
// Example: filters.person.threshold -> filters.threshold
|
||||
// Handle filters section - skip the dynamic filter object name. Route
|
||||
// to `filters_attribute.<field>` when the dynamic key is an attribute
|
||||
// label (face, license_plate, courier logos) so attribute filter fields
|
||||
// pick up the attribute-worded translations emitted by
|
||||
// generate_config_translations.py.
|
||||
// Example: filters.person.threshold -> filters.threshold
|
||||
// Example: filters.face.min_area -> filters_attribute.min_area
|
||||
const filtersIndex = stringSegments.indexOf("filters");
|
||||
if (filtersIndex !== -1 && stringSegments.length > filtersIndex + 2) {
|
||||
const filterKey = stringSegments[filtersIndex + 1];
|
||||
const allAttributes = getEffectiveAttributeLabels(
|
||||
formContext?.fullConfig,
|
||||
formContext?.fullCameraConfig,
|
||||
formContext?.level,
|
||||
);
|
||||
const sectionWord = allAttributes.includes(filterKey)
|
||||
? "filters_attribute"
|
||||
: "filters";
|
||||
const normalized = [
|
||||
...stringSegments.slice(0, filtersIndex + 1),
|
||||
...stringSegments.slice(0, filtersIndex),
|
||||
sectionWord,
|
||||
...stringSegments.slice(filtersIndex + 2),
|
||||
];
|
||||
return normalized.join(".");
|
||||
|
||||
Reference in New Issue
Block a user