Add attributes to UI filters list (#23250)
Some checks are pending
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:
Josh Hawkins 2026-05-19 09:31:50 -05:00 committed by GitHub
parent 4fdc107987
commit b1de5e2290
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 535 additions and 43 deletions

View File

@ -629,10 +629,11 @@ class FrigateConfig(FrigateBaseModel):
# set default min_score for object attributes # set default min_score for object attributes
for attribute in self.model.all_attributes: for attribute in self.model.all_attributes:
if not self.objects.filters.get(attribute): existing = self.objects.filters.get(attribute)
if existing is None:
self.objects.filters[attribute] = FilterConfig(min_score=0.7) self.objects.filters[attribute] = FilterConfig(min_score=0.7)
elif self.objects.filters[attribute].min_score == 0.5: elif "min_score" not in existing.model_fields_set:
self.objects.filters[attribute].min_score = 0.7 existing.min_score = 0.7
# auto detect hwaccel args # auto detect hwaccel args
if self.ffmpeg.hwaccel_args == "auto": if self.ffmpeg.hwaccel_args == "auto":

View File

@ -1673,5 +1673,60 @@ class TestConfig(unittest.TestCase):
self.assertRaises(ValueError, lambda: FrigateConfig(**config)) self.assertRaises(ValueError, lambda: FrigateConfig(**config))
class TestAttributeFilterDefaults(unittest.TestCase):
"""Verify attribute filter min_score handling at config load."""
def setUp(self):
self.minimal = {
"mqtt": {"host": "mqtt"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
def _build_config(self, object_filters: dict | None = None) -> FrigateConfig:
config = deep_merge({}, self.minimal)
if object_filters is not None:
config.setdefault("objects", {})["filters"] = object_filters
return FrigateConfig(**config)
def test_attribute_with_no_filter_gets_default_min_score(self):
"""Attribute with no user-provided filter gets created with min_score=0.7."""
config = self._build_config()
face_filter = config.objects.filters.get("face")
self.assertIsNotNone(face_filter)
self.assertEqual(face_filter.min_score, 0.7)
def test_attribute_filter_without_min_score_gets_bumped(self):
"""If user sets some FilterConfig field but not min_score, min_score is bumped to 0.7."""
config = self._build_config({"face": {"min_area": 500}})
face_filter = config.objects.filters["face"]
self.assertEqual(face_filter.min_area, 500)
self.assertEqual(face_filter.min_score, 0.7)
def test_attribute_filter_explicit_min_score_half_is_preserved(self):
"""User-provided min_score=0.5 must NOT be silently rewritten to 0.7."""
config = self._build_config({"face": {"min_score": 0.5}})
face_filter = config.objects.filters["face"]
self.assertEqual(face_filter.min_score, 0.5)
def test_attribute_filter_explicit_min_score_other_value_is_preserved(self):
"""Sanity: explicit non-0.5 values pass through unchanged."""
config = self._build_config({"face": {"min_score": 0.3}})
face_filter = config.objects.filters["face"]
self.assertEqual(face_filter.min_score, 0.3)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main(verbosity=2) unittest.main(verbosity=2)

View File

@ -364,6 +364,64 @@ def main():
continue continue
section_data.pop(key, None) section_data.pop(key, None)
if field_name == "objects":
# Produce a parallel `filters_attribute` block alongside `filters`,
# with object-wording rewritten for attribute filters (face,
# license_plate, courier logos). The frontend's
# buildTranslationPath routes `filters.<attr>.<field>` lookups to
# `filters_attribute.<field>` when `<attr>` is in
# `model.all_attributes`. Keep this rewrite list explicit rather
# than running a blanket s/object/attribute/ so unrelated
# descriptions (e.g. "JSON object") never accidentally flip.
filters_block = section_data.get("filters")
if isinstance(filters_block, dict):
attribute_rewrites = [
("Object filters", "Attribute filters"),
("detected objects", "detected attributes"),
("object area", "attribute area"),
("object type", "attribute"),
("the object", "the attribute"),
]
# Per-field overrides for cases where the generic rewrite
# doesn't capture the attribute-specific semantics. Keys
# match the FilterConfig field name; values are partial
# overrides applied AFTER the generic rewrites.
attribute_field_overrides: Dict[str, Dict[str, str]] = {
"min_score": {
"description": (
"Minimum single-frame detection confidence required "
"to associate this attribute with its parent object."
),
},
}
def rewrite(text: str) -> str:
for source, replacement in attribute_rewrites:
text = text.replace(source, replacement)
return text
attribute_variant: Dict[str, Any] = {}
for key, value in filters_block.items():
if key in ("label", "description"):
if isinstance(value, str):
attribute_variant[key] = rewrite(value)
continue
if not isinstance(value, dict):
continue
field_trans: Dict[str, str] = {}
if isinstance(value.get("label"), str):
field_trans["label"] = rewrite(value["label"])
if isinstance(value.get("description"), str):
field_trans["description"] = rewrite(value["description"])
overrides = attribute_field_overrides.get(key)
if overrides:
field_trans.update(overrides)
if field_trans:
attribute_variant[key] = field_trans
if attribute_variant:
section_data["filters_attribute"] = attribute_variant
if not section_data: if not section_data:
logger.warning(f"No translations found for section: {field_name}") logger.warning(f"No translations found for section: {field_name}")
continue continue

View File

@ -950,4 +950,4 @@
"label": "Original camera state", "label": "Original camera state",
"description": "Keep track of original state of camera." "description": "Keep track of original state of camera."
} }
} }

View File

@ -921,6 +921,41 @@
"label": "Original GenAI state", "label": "Original GenAI state",
"description": "Indicates whether GenAI was enabled in the original static config." "description": "Indicates whether GenAI was enabled in the original static config."
} }
},
"filters_attribute": {
"label": "Attribute filters",
"description": "Filters applied to detected attributes to reduce false positives (area, ratio, confidence).",
"min_area": {
"label": "Minimum attribute area",
"description": "Minimum bounding box area (pixels or percentage) required for this attribute. Can be pixels (int) or percentage (float between 0.000001 and 0.99)."
},
"max_area": {
"label": "Maximum attribute area",
"description": "Maximum bounding box area (pixels or percentage) allowed for this attribute. Can be pixels (int) or percentage (float between 0.000001 and 0.99)."
},
"min_ratio": {
"label": "Minimum aspect ratio",
"description": "Minimum width/height ratio required for the bounding box to qualify."
},
"max_ratio": {
"label": "Maximum aspect ratio",
"description": "Maximum width/height ratio allowed for the bounding box to qualify."
},
"threshold": {
"label": "Confidence threshold",
"description": "Average detection confidence threshold required for the attribute to be considered a true positive."
},
"min_score": {
"label": "Minimum confidence",
"description": "Minimum single-frame detection confidence required to associate this attribute with its parent object."
},
"mask": {
"label": "Filter mask",
"description": "Polygon coordinates defining where this filter applies within the frame."
},
"raw_mask": {
"label": "Raw Mask"
}
} }
}, },
"record": { "record": {
@ -1597,4 +1632,4 @@
"description": "Ignore time synchronization differences between camera and Frigate server for ONVIF communication." "description": "Ignore time synchronization differences between camera and Frigate server for ONVIF communication."
} }
} }
} }

View File

@ -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"; import type { SectionConfigOverrides } from "./types";
// Attribute labels (face, license_plate, Frigate+ couriers like DHL/Amazon, // Attribute labels (face, license_plate, Frigate+ couriers like DHL/Amazon,
// etc.) are populated into objects.filters by the backend even when the // etc.) are populated into objects.filters by the backend for every
// model can't actually detect them. They aren't user-settable, so hide any // attribute the model knows about.
// `filters.<attr>` patterns from forms and override comparisons. //
const hideAttributeFilters = (config: FrigateConfig): string[] => // - Untracked attributes: hide the whole `filters.<attr>` collapsible.
(config.model?.all_attributes ?? []).map((attr) => `filters.${attr}`); // - 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 = { const objects: SectionConfigOverrides = {
base: { base: {

View File

@ -308,11 +308,30 @@ export function ConfigSection({
// Get section schema using cached hook // Get section schema using cached hook
const sectionSchema = useSectionSchema(sectionPath, effectiveLevel); 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( const modifiedSchema = useMemo(
() => () =>
modifySchemaForSection(sectionPath, level, sectionSchema ?? undefined), modifySchemaForSection(
[sectionPath, level, sectionSchema], 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) // 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 // When editing a profile, hide fields that require a restart since they
// cannot take effect via profile switching alone. // cannot take effect via profile switching alone.
const effectiveHiddenFields = useMemo(() => { 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) { if (!profileName || !sectionConfig.restartRequired?.length) {
return base; return base;
} }
@ -394,6 +425,10 @@ export function ConfigSection({
sectionConfig.hiddenFields, sectionConfig.hiddenFields,
sectionConfig.restartRequired, sectionConfig.restartRequired,
config, config,
effectiveLevel,
cameraName,
level,
rawFormData,
]); ]);
const sanitizeSectionData = useCallback( const sanitizeSectionData = useCallback(

View File

@ -20,6 +20,7 @@ import type { ProfilesApiResponse } from "@/types/profile";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name"; import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import { formatList } from "@/utils/stringUtil"; import { formatList } from "@/utils/stringUtil";
import { import {
buildHiddenFieldContext,
getEffectiveHiddenFields, getEffectiveHiddenFields,
pathMatchesHiddenPattern, pathMatchesHiddenPattern,
} from "@/utils/configUtil"; } from "@/utils/configUtil";
@ -187,7 +188,7 @@ export function CameraOverridesBadge({ sectionPath, className }: Props) {
const hiddenFields = getEffectiveHiddenFields( const hiddenFields = getEffectiveHiddenFields(
sectionPath, sectionPath,
"global", "global",
config, buildHiddenFieldContext(config, "global"),
); );
if (hiddenFields.length === 0) return rawEntries; if (hiddenFields.length === 0) return rawEntries;
return rawEntries return rawEntries

View File

@ -9,7 +9,8 @@
import { RJSFSchema } from "@rjsf/utils"; import { RJSFSchema } from "@rjsf/utils";
import { applySchemaDefaults } from "@/lib/config-schema"; import { applySchemaDefaults } from "@/lib/config-schema";
import { isJsonObject } from "@/lib/utils"; 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. * 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 * - detectors: Strip the "default" field to prevent RJSF from merging the
* default {"cpu": {"type": "cpu"}} with stored detector keys. * 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( export function modifySchemaForSection(
sectionPath: string, sectionPath: string,
level: string, level: string,
schema: RJSFSchema | undefined, schema: RJSFSchema | undefined,
ctx?: HiddenFieldContext,
): RJSFSchema | undefined { ): RJSFSchema | undefined {
if (!schema || !isSpecialCaseSection(sectionPath, level)) { if (!schema) return schema;
if (sectionPath === "objects") {
return modifyObjectsSchema(schema, ctx);
}
if (!isSpecialCaseSection(sectionPath, level)) {
return schema; return schema;
} }
@ -79,6 +95,151 @@ export function modifySchemaForSection(
return schema; 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. * Get effective defaults for sections with special schema patterns.
* *

View File

@ -6,6 +6,7 @@
*/ */
import type { ConfigFormContext } from "@/types/configForm"; import type { ConfigFormContext } from "@/types/configForm";
import { getEffectiveAttributeLabels } from "@/utils/configUtil";
const isRecord = (value: unknown): value is Record<string, unknown> => const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null; typeof value === "object" && value !== null;
@ -70,12 +71,27 @@ export function buildTranslationPath(
(segment): segment is string => typeof segment === "string", (segment): segment is string => typeof segment === "string",
); );
// Handle filters section - skip the dynamic filter object name // Handle filters section - skip the dynamic filter object name. Route
// Example: filters.person.threshold -> filters.threshold // 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"); const filtersIndex = stringSegments.indexOf("filters");
if (filtersIndex !== -1 && stringSegments.length > filtersIndex + 2) { 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 = [ const normalized = [
...stringSegments.slice(0, filtersIndex + 1), ...stringSegments.slice(0, filtersIndex),
sectionWord,
...stringSegments.slice(filtersIndex + 2), ...stringSegments.slice(filtersIndex + 2),
]; ];
return normalized.join("."); return normalized.join(".");

View File

@ -10,6 +10,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
import { JsonObject, JsonValue } from "@/types/configForm"; import { JsonObject, JsonValue } from "@/types/configForm";
import { isJsonObject } from "@/lib/utils"; import { isJsonObject } from "@/lib/utils";
import { import {
buildHiddenFieldContext,
getBaseCameraSectionValue, getBaseCameraSectionValue,
getEffectiveHiddenFields, getEffectiveHiddenFields,
pathMatchesHiddenPattern, pathMatchesHiddenPattern,
@ -286,7 +287,7 @@ export function useConfigOverride({
const hiddenFields = getEffectiveHiddenFields( const hiddenFields = getEffectiveHiddenFields(
sectionPath, sectionPath,
"camera", "camera",
config, buildHiddenFieldContext(config, "camera", cameraName),
); );
const collapsedGlobal = stripHiddenPaths( const collapsedGlobal = stripHiddenPaths(
collapseEmpty(normalizedGlobalValue), collapseEmpty(normalizedGlobalValue),
@ -439,7 +440,11 @@ export function useAllCameraOverrides(
getBaseCameraSectionValue(config, cameraName, key), getBaseCameraSectionValue(config, cameraName, key),
); );
const hiddenFields = getEffectiveHiddenFields(key, "camera", config); const hiddenFields = getEffectiveHiddenFields(
key,
"camera",
buildHiddenFieldContext(config, "camera", cameraName),
);
const collapsedGlobal = stripHiddenPaths( const collapsedGlobal = stripHiddenPaths(
collapseEmpty(globalValue), collapseEmpty(globalValue),
hiddenFields, hiddenFields,
@ -795,7 +800,7 @@ export function useCameraSectionDeltas(
const hiddenFields = getEffectiveHiddenFields( const hiddenFields = getEffectiveHiddenFields(
sectionPath, sectionPath,
"camera", "camera",
config, buildHiddenFieldContext(config, "camera", cameraName),
); );
const deltas: FieldDelta[] = []; const deltas: FieldDelta[] = [];
@ -864,7 +869,7 @@ export function useProfileSectionDeltas(
const hiddenFields = getEffectiveHiddenFields( const hiddenFields = getEffectiveHiddenFields(
sectionPath, sectionPath,
"camera", "camera",
config, buildHiddenFieldContext(config, "camera", cameraName),
); );
const deltas: FieldDelta[] = []; const deltas: FieldDelta[] = [];

View File

@ -89,6 +89,7 @@ import { mutate } from "swr";
import { RJSFSchema } from "@rjsf/utils"; import { RJSFSchema } from "@rjsf/utils";
import { import {
buildConfigDataForPath, buildConfigDataForPath,
buildHiddenFieldContext,
flattenOverrides, flattenOverrides,
getSectionConfig, getSectionConfig,
parseProfileFromSectionPath, parseProfileFromSectionPath,
@ -851,11 +852,11 @@ export default function Settings() {
// they stay in sync with what the embedded forms strip on render // they stay in sync with what the embedded forms strip on render
const detectorHiddenFields = resolveHiddenFieldEntries( const detectorHiddenFields = resolveHiddenFieldEntries(
getSectionConfig("detectors", "global").hiddenFields, getSectionConfig("detectors", "global").hiddenFields,
config, buildHiddenFieldContext(config, "global"),
); );
const modelHiddenFields = resolveHiddenFieldEntries( const modelHiddenFields = resolveHiddenFieldEntries(
getSectionConfig("model", "global").hiddenFields, getSectionConfig("model", "global").hiddenFields,
config, buildHiddenFieldContext(config, "global"),
); );
const sanitizedDetectors = const sanitizedDetectors =
pendingDetectors !== undefined pendingDetectors !== undefined

View File

@ -13,7 +13,19 @@ export type JsonArray = JsonValue[];
export type ConfigSectionData = JsonObject; export type ConfigSectionData = JsonObject;
export type HiddenFieldEntry = string | ((config: FrigateConfig) => string[]); export type HiddenFieldContext = {
fullConfig: FrigateConfig;
fullCameraConfig?: CameraConfig;
level: "global" | "camera" | "replay";
cameraName?: string;
// Saved form data for the current section/scope (i.e. rawFormData in
// BaseSection.tsx). Not the user's in-flight RJSF edits. Optional because
// most hidden-field callsites compute patterns without a specific section
// value on hand; resolvers fall back to fullCameraConfig / fullConfig.
formData?: ConfigSectionData;
};
export type HiddenFieldEntry = string | ((ctx: HiddenFieldContext) => string[]);
export type ConfigFormContext = { export type ConfigFormContext = {
level?: "global" | "camera"; level?: "global" | "camera";

View File

@ -522,8 +522,8 @@ export interface FrigateConfig {
path: string | null; path: string | null;
width: number; width: number;
colormap: { [key: string]: [number, number, number] }; colormap: { [key: string]: [number, number, number] };
attributes_map: { [key: string]: [string] }; attributes_map: { [key: string]: string[] };
all_attributes: [string]; all_attributes: string[];
plus?: { plus?: {
name: string; name: string;
id: string; id: string;

View File

@ -19,9 +19,10 @@ import {
sanitizeOverridesForSection, sanitizeOverridesForSection,
} from "@/components/config-form/sections/section-special-cases"; } from "@/components/config-form/sections/section-special-cases";
import type { RJSFSchema } from "@rjsf/utils"; import type { RJSFSchema } from "@rjsf/utils";
import type { FrigateConfig } from "@/types/frigateConfig"; import type { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import type { import type {
ConfigSectionData, ConfigSectionData,
HiddenFieldContext,
JsonObject, JsonObject,
JsonValue, JsonValue,
} from "@/types/configForm"; } from "@/types/configForm";
@ -568,6 +569,17 @@ export function prepareSectionSavePayload(opts: {
schemaSection, schemaSection,
level, level,
sectionSchema, sectionSchema,
config
? {
fullConfig: config,
fullCameraConfig:
level === "camera" && cameraName
? config.cameras?.[cameraName]
: undefined,
level,
cameraName,
}
: undefined,
); );
// Compute rawFormData (the current stored value for this section) // Compute rawFormData (the current stored value for this section)
@ -615,10 +627,16 @@ export function prepareSectionSavePayload(opts: {
// For profile sections, also hide restart-required fields to match // For profile sections, also hide restart-required fields to match
// effectiveHiddenFields in BaseSection (prevents spurious deletion markers // effectiveHiddenFields in BaseSection (prevents spurious deletion markers
// for fields that are hidden from the form during profile editing). // for fields that are hidden from the form during profile editing).
const resolvedHidden = resolveHiddenFieldEntries( const resolvedHidden = resolveHiddenFieldEntries(sectionConfig.hiddenFields, {
sectionConfig.hiddenFields, fullConfig: config,
config, fullCameraConfig:
); level === "camera" && cameraName
? config.cameras?.[cameraName]
: undefined,
level,
cameraName,
formData: rawFormData as ConfigSectionData,
});
const hiddenFieldsForSanitize = const hiddenFieldsForSanitize =
profileInfo.isProfile && sectionConfig.restartRequired?.length profileInfo.isProfile && sectionConfig.restartRequired?.length
? [...new Set([...resolvedHidden, ...sectionConfig.restartRequired])] ? [...new Set([...resolvedHidden, ...sectionConfig.restartRequired])]
@ -731,32 +749,77 @@ export function getSectionConfig(
return mergeSectionConfig(entry.base, overrides); return mergeSectionConfig(entry.base, overrides);
} }
/**
* Resolve the effective attribute label set at a given scope. At camera
* (and replay) scope on a dedicated LPR camera (`camera.type === "lpr"`),
* `license_plate` is treated as a regular tracked object not an
* attribute to match the backend's per-camera carve-out in
* `frigate/video/detect.py`. Returns the full attribute list at global
* scope and for non-LPR cameras.
*/
export function getEffectiveAttributeLabels(
fullConfig: FrigateConfig | undefined,
fullCameraConfig: CameraConfig | undefined,
level: "global" | "camera" | "replay" | undefined,
): string[] {
const all = fullConfig?.model?.all_attributes ?? [];
if (level !== "global" && fullCameraConfig?.type === "lpr") {
return all.filter((attr) => attr !== "license_plate");
}
return all;
}
/**
* Build a `HiddenFieldContext` for the common case where a callsite has
* `config`, an optional `cameraName`, and a level, but no per-section
* saved form data to thread through. Resolvers that don't read `formData`
* (which is most of them) just fall through to `fullCameraConfig` /
* `fullConfig`.
*/
export function buildHiddenFieldContext(
config: FrigateConfig | undefined,
level: "global" | "camera" | "replay",
cameraName?: string,
): HiddenFieldContext | undefined {
if (!config) return undefined;
return {
fullConfig: config,
fullCameraConfig:
level !== "global" && cameraName
? config.cameras?.[cameraName]
: undefined,
level,
cameraName,
};
}
/** /**
* Resolve the effective hidden-field patterns for a section. Each entry in * Resolve the effective hidden-field patterns for a section. Each entry in
* `hiddenFields` is either a literal pattern or a function that produces * `hiddenFields` is either a literal pattern or a function that produces
* patterns from the loaded config (e.g. `filters.<attr>` for each * patterns from the loaded config and scope (e.g. `filters.<attr>` for each
* `model.all_attributes` entry on the objects section). * `model.all_attributes` entry on the objects section, gated by the
* effective `objects.track` list at the current scope).
*/ */
export function getEffectiveHiddenFields( export function getEffectiveHiddenFields(
sectionKey: string, sectionKey: string,
level: "global" | "camera" | "replay", level: "global" | "camera" | "replay",
config: FrigateConfig | undefined, ctx: HiddenFieldContext | undefined,
): string[] { ): string[] {
return resolveHiddenFieldEntries( return resolveHiddenFieldEntries(
getSectionConfig(sectionKey, level).hiddenFields, getSectionConfig(sectionKey, level).hiddenFields,
config, ctx,
); );
} }
export function resolveHiddenFieldEntries( export function resolveHiddenFieldEntries(
entries: SectionConfig["hiddenFields"] | undefined, entries: SectionConfig["hiddenFields"] | undefined,
config: FrigateConfig | undefined, ctx: HiddenFieldContext | undefined,
): string[] { ): string[] {
if (!entries || entries.length === 0) return []; if (!entries || entries.length === 0) return [];
const result: string[] = []; const result: string[] = [];
for (const entry of entries) { for (const entry of entries) {
if (typeof entry === "function") { if (typeof entry === "function") {
if (config) result.push(...entry(config)); if (ctx) result.push(...entry(ctx));
} else { } else {
result.push(entry); result.push(entry);
} }

View File

@ -51,6 +51,7 @@ import { ConfigSectionTemplate } from "@/components/config-form/sections";
import { ConfigMessageBanner } from "@/components/config-form/ConfigMessageBanner"; import { ConfigMessageBanner } from "@/components/config-form/ConfigMessageBanner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { import {
buildHiddenFieldContext,
getSectionConfig, getSectionConfig,
resolveHiddenFieldEntries, resolveHiddenFieldEntries,
sanitizeSectionData, sanitizeSectionData,
@ -226,7 +227,7 @@ export default function DetectorsAndModelSettingsView({
() => () =>
resolveHiddenFieldEntries( resolveHiddenFieldEntries(
getSectionConfig("detectors", "global").hiddenFields, getSectionConfig("detectors", "global").hiddenFields,
config, buildHiddenFieldContext(config, "global"),
), ),
[config], [config],
); );
@ -234,7 +235,7 @@ export default function DetectorsAndModelSettingsView({
() => () =>
resolveHiddenFieldEntries( resolveHiddenFieldEntries(
getSectionConfig("model", "global").hiddenFields, getSectionConfig("model", "global").hiddenFields,
config, buildHiddenFieldContext(config, "global"),
), ),
[config], [config],
); );