From b1de5e22909ae393c520a2da04d437f6533828fa Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Tue, 19 May 2026 09:31:50 -0500 Subject: [PATCH] Add attributes to UI filters list (#23250) * 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 --- frigate/config/config.py | 7 +- frigate/test/test_config.py | 55 ++++++ generate_config_translations.py | 58 ++++++ web/public/locales/en/config/cameras.json | 2 +- web/public/locales/en/config/global.json | 37 +++- .../config-form/section-configs/objects.ts | 60 ++++++- .../config-form/sections/BaseSection.tsx | 43 ++++- .../sections/CameraOverridesBadge.tsx | 3 +- .../sections/section-special-cases.ts | 165 +++++++++++++++++- .../config-form/theme/utils/i18n.ts | 22 ++- web/src/hooks/use-config-override.ts | 13 +- web/src/pages/Settings.tsx | 5 +- web/src/types/configForm.ts | 14 +- web/src/types/frigateConfig.ts | 4 +- web/src/utils/configUtil.ts | 85 +++++++-- .../DetectorsAndModelSettingsView.tsx | 5 +- 16 files changed, 535 insertions(+), 43 deletions(-) diff --git a/frigate/config/config.py b/frigate/config/config.py index 04dd46a67..7aa6dac59 100644 --- a/frigate/config/config.py +++ b/frigate/config/config.py @@ -629,10 +629,11 @@ class FrigateConfig(FrigateBaseModel): # set default min_score for object 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) - elif self.objects.filters[attribute].min_score == 0.5: - self.objects.filters[attribute].min_score = 0.7 + elif "min_score" not in existing.model_fields_set: + existing.min_score = 0.7 # auto detect hwaccel args if self.ffmpeg.hwaccel_args == "auto": diff --git a/frigate/test/test_config.py b/frigate/test/test_config.py index 48553465d..6490a6509 100644 --- a/frigate/test/test_config.py +++ b/frigate/test/test_config.py @@ -1673,5 +1673,60 @@ class TestConfig(unittest.TestCase): 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__": unittest.main(verbosity=2) diff --git a/generate_config_translations.py b/generate_config_translations.py index 9bc830855..7f9c9bc50 100644 --- a/generate_config_translations.py +++ b/generate_config_translations.py @@ -364,6 +364,64 @@ def main(): continue 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..` lookups to + # `filters_attribute.` when `` 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: logger.warning(f"No translations found for section: {field_name}") continue diff --git a/web/public/locales/en/config/cameras.json b/web/public/locales/en/config/cameras.json index f645dd33a..4f2c0ea01 100644 --- a/web/public/locales/en/config/cameras.json +++ b/web/public/locales/en/config/cameras.json @@ -950,4 +950,4 @@ "label": "Original camera state", "description": "Keep track of original state of camera." } -} \ No newline at end of file +} diff --git a/web/public/locales/en/config/global.json b/web/public/locales/en/config/global.json index b10f0a7af..1f5c39248 100644 --- a/web/public/locales/en/config/global.json +++ b/web/public/locales/en/config/global.json @@ -921,6 +921,41 @@ "label": "Original GenAI state", "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": { @@ -1597,4 +1632,4 @@ "description": "Ignore time synchronization differences between camera and Frigate server for ONVIF communication." } } -} \ No newline at end of file +} diff --git a/web/src/components/config-form/section-configs/objects.ts b/web/src/components/config-form/section-configs/objects.ts index 3d27abb01..371a1f514 100644 --- a/web/src/components/config-form/section-configs/objects.ts +++ b/web/src/components/config-form/section-configs/objects.ts @@ -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.` 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.` 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: { diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index 9245fa240..e61ac8a6a 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -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( diff --git a/web/src/components/config-form/sections/CameraOverridesBadge.tsx b/web/src/components/config-form/sections/CameraOverridesBadge.tsx index 466934a77..9d3dde29d 100644 --- a/web/src/components/config-form/sections/CameraOverridesBadge.tsx +++ b/web/src/components/config-form/sections/CameraOverridesBadge.tsx @@ -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 diff --git a/web/src/components/config-form/sections/section-special-cases.ts b/web/src/components/config-form/sections/section-special-cases.ts index 62a4bfa85..256c275eb 100644 --- a/web/src/components/config-form/sections/section-special-cases.ts +++ b/web/src/components/config-form/sections/section-special-cases.ts @@ -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.` 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 }) + .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 }).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 }).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 = { + ...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. * diff --git a/web/src/components/config-form/theme/utils/i18n.ts b/web/src/components/config-form/theme/utils/i18n.ts index 5a2702065..a5f7ea152 100644 --- a/web/src/components/config-form/theme/utils/i18n.ts +++ b/web/src/components/config-form/theme/utils/i18n.ts @@ -6,6 +6,7 @@ */ import type { ConfigFormContext } from "@/types/configForm"; +import { getEffectiveAttributeLabels } from "@/utils/configUtil"; const isRecord = (value: unknown): value is Record => 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.` 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("."); diff --git a/web/src/hooks/use-config-override.ts b/web/src/hooks/use-config-override.ts index 90ce71729..2b0ed2cbd 100644 --- a/web/src/hooks/use-config-override.ts +++ b/web/src/hooks/use-config-override.ts @@ -10,6 +10,7 @@ import { FrigateConfig } from "@/types/frigateConfig"; import { JsonObject, JsonValue } from "@/types/configForm"; import { isJsonObject } from "@/lib/utils"; import { + buildHiddenFieldContext, getBaseCameraSectionValue, getEffectiveHiddenFields, pathMatchesHiddenPattern, @@ -286,7 +287,7 @@ export function useConfigOverride({ const hiddenFields = getEffectiveHiddenFields( sectionPath, "camera", - config, + buildHiddenFieldContext(config, "camera", cameraName), ); const collapsedGlobal = stripHiddenPaths( collapseEmpty(normalizedGlobalValue), @@ -439,7 +440,11 @@ export function useAllCameraOverrides( getBaseCameraSectionValue(config, cameraName, key), ); - const hiddenFields = getEffectiveHiddenFields(key, "camera", config); + const hiddenFields = getEffectiveHiddenFields( + key, + "camera", + buildHiddenFieldContext(config, "camera", cameraName), + ); const collapsedGlobal = stripHiddenPaths( collapseEmpty(globalValue), hiddenFields, @@ -795,7 +800,7 @@ export function useCameraSectionDeltas( const hiddenFields = getEffectiveHiddenFields( sectionPath, "camera", - config, + buildHiddenFieldContext(config, "camera", cameraName), ); const deltas: FieldDelta[] = []; @@ -864,7 +869,7 @@ export function useProfileSectionDeltas( const hiddenFields = getEffectiveHiddenFields( sectionPath, "camera", - config, + buildHiddenFieldContext(config, "camera", cameraName), ); const deltas: FieldDelta[] = []; diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 6ebfa9263..c83dbcc1c 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -89,6 +89,7 @@ import { mutate } from "swr"; import { RJSFSchema } from "@rjsf/utils"; import { buildConfigDataForPath, + buildHiddenFieldContext, flattenOverrides, getSectionConfig, parseProfileFromSectionPath, @@ -851,11 +852,11 @@ export default function Settings() { // they stay in sync with what the embedded forms strip on render const detectorHiddenFields = resolveHiddenFieldEntries( getSectionConfig("detectors", "global").hiddenFields, - config, + buildHiddenFieldContext(config, "global"), ); const modelHiddenFields = resolveHiddenFieldEntries( getSectionConfig("model", "global").hiddenFields, - config, + buildHiddenFieldContext(config, "global"), ); const sanitizedDetectors = pendingDetectors !== undefined diff --git a/web/src/types/configForm.ts b/web/src/types/configForm.ts index 03ecd3e4d..1f4c57c39 100644 --- a/web/src/types/configForm.ts +++ b/web/src/types/configForm.ts @@ -13,7 +13,19 @@ export type JsonArray = JsonValue[]; 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 = { level?: "global" | "camera"; diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index 2b9a05a1a..68a282220 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -522,8 +522,8 @@ export interface FrigateConfig { path: string | null; width: number; colormap: { [key: string]: [number, number, number] }; - attributes_map: { [key: string]: [string] }; - all_attributes: [string]; + attributes_map: { [key: string]: string[] }; + all_attributes: string[]; plus?: { name: string; id: string; diff --git a/web/src/utils/configUtil.ts b/web/src/utils/configUtil.ts index 80c940cb7..4b6ffefb7 100644 --- a/web/src/utils/configUtil.ts +++ b/web/src/utils/configUtil.ts @@ -19,9 +19,10 @@ import { sanitizeOverridesForSection, } from "@/components/config-form/sections/section-special-cases"; import type { RJSFSchema } from "@rjsf/utils"; -import type { FrigateConfig } from "@/types/frigateConfig"; +import type { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import type { ConfigSectionData, + HiddenFieldContext, JsonObject, JsonValue, } from "@/types/configForm"; @@ -568,6 +569,17 @@ export function prepareSectionSavePayload(opts: { schemaSection, level, sectionSchema, + config + ? { + fullConfig: config, + fullCameraConfig: + level === "camera" && cameraName + ? config.cameras?.[cameraName] + : undefined, + level, + cameraName, + } + : undefined, ); // 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 // effectiveHiddenFields in BaseSection (prevents spurious deletion markers // for fields that are hidden from the form during profile editing). - const resolvedHidden = resolveHiddenFieldEntries( - sectionConfig.hiddenFields, - config, - ); + const resolvedHidden = resolveHiddenFieldEntries(sectionConfig.hiddenFields, { + fullConfig: config, + fullCameraConfig: + level === "camera" && cameraName + ? config.cameras?.[cameraName] + : undefined, + level, + cameraName, + formData: rawFormData as ConfigSectionData, + }); const hiddenFieldsForSanitize = profileInfo.isProfile && sectionConfig.restartRequired?.length ? [...new Set([...resolvedHidden, ...sectionConfig.restartRequired])] @@ -731,32 +749,77 @@ export function getSectionConfig( 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 * `hiddenFields` is either a literal pattern or a function that produces - * patterns from the loaded config (e.g. `filters.` for each - * `model.all_attributes` entry on the objects section). + * patterns from the loaded config and scope (e.g. `filters.` for each + * `model.all_attributes` entry on the objects section, gated by the + * effective `objects.track` list at the current scope). */ export function getEffectiveHiddenFields( sectionKey: string, level: "global" | "camera" | "replay", - config: FrigateConfig | undefined, + ctx: HiddenFieldContext | undefined, ): string[] { return resolveHiddenFieldEntries( getSectionConfig(sectionKey, level).hiddenFields, - config, + ctx, ); } export function resolveHiddenFieldEntries( entries: SectionConfig["hiddenFields"] | undefined, - config: FrigateConfig | undefined, + ctx: HiddenFieldContext | undefined, ): string[] { if (!entries || entries.length === 0) return []; const result: string[] = []; for (const entry of entries) { if (typeof entry === "function") { - if (config) result.push(...entry(config)); + if (ctx) result.push(...entry(ctx)); } else { result.push(entry); } diff --git a/web/src/views/settings/DetectorsAndModelSettingsView.tsx b/web/src/views/settings/DetectorsAndModelSettingsView.tsx index 615ab3296..ebbad2b52 100644 --- a/web/src/views/settings/DetectorsAndModelSettingsView.tsx +++ b/web/src/views/settings/DetectorsAndModelSettingsView.tsx @@ -51,6 +51,7 @@ import { ConfigSectionTemplate } from "@/components/config-form/sections"; import { ConfigMessageBanner } from "@/components/config-form/ConfigMessageBanner"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { + buildHiddenFieldContext, getSectionConfig, resolveHiddenFieldEntries, sanitizeSectionData, @@ -226,7 +227,7 @@ export default function DetectorsAndModelSettingsView({ () => resolveHiddenFieldEntries( getSectionConfig("detectors", "global").hiddenFields, - config, + buildHiddenFieldContext(config, "global"), ), [config], ); @@ -234,7 +235,7 @@ export default function DetectorsAndModelSettingsView({ () => resolveHiddenFieldEntries( getSectionConfig("model", "global").hiddenFields, - config, + buildHiddenFieldContext(config, "global"), ), [config], );