From 3a08b3d54b71d575416cb693bee88fc273e15423 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:14:58 -0500 Subject: [PATCH] fix zones - Fix zone colors not matching across profiles by falling back to base zone color when profile zone data lacks a color field - Use base_config for base-layer values in masks/zones view so profile-merged values don't pollute the base config editing view - Handle zones separately in profile manager snapshot/restore since ZoneConfig requires special serialization (color as private attr, contour generation) - Inherit base zone color and generate contours for profile zone overrides in profile manager --- frigate/config/profile_manager.py | 48 +++++++++++++++++--- web/src/views/settings/MasksAndZonesView.tsx | 38 +++++++++------- 2 files changed, 63 insertions(+), 23 deletions(-) diff --git a/frigate/config/profile_manager.py b/frigate/config/profile_manager.py index 36c299989..37dc1c909 100644 --- a/frigate/config/profile_manager.py +++ b/frigate/config/profile_manager.py @@ -29,6 +29,7 @@ PROFILE_SECTION_UPDATES: dict[str, CameraConfigUpdateEnum] = { "record": CameraConfigUpdateEnum.record, "review": CameraConfigUpdateEnum.review, "snapshots": CameraConfigUpdateEnum.snapshots, + "zones": CameraConfigUpdateEnum.zones, } PERSISTENCE_FILE = Path(CONFIG_DIR) / ".active_profile" @@ -60,14 +61,36 @@ class ProfileManager: self._base_enabled[cam_name] = cam_config.enabled self._base_zones[cam_name] = copy.deepcopy(cam_config.zones) for section in PROFILE_SECTION_UPDATES: - section_config = getattr(cam_config, section, None) - if section_config is not None: + section_value = getattr(cam_config, section, None) + if section_value is None: + continue + + if section == "zones": + # zones is a dict of ZoneConfig models + self._base_configs[cam_name][section] = { + name: zone.model_dump() + for name, zone in section_value.items() + } + self._base_api_configs[cam_name][section] = { + name: { + **zone.model_dump( + mode="json", + warnings="none", + exclude_none=True, + ), + "color": zone.color, + } + for name, zone in section_value.items() + } + else: self._base_configs[cam_name][section] = ( - section_config.model_dump() + section_value.model_dump() ) self._base_api_configs[cam_name][section] = ( - section_config.model_dump( - mode="json", warnings="none", exclude_none=True + section_value.model_dump( + mode="json", + warnings="none", + exclude_none=True, ) ) @@ -154,9 +177,11 @@ class ProfileManager: cam_config.zones = copy.deepcopy(base_zones) changed.setdefault(cam_name, set()).add("zones") - # Restore section configs + # Restore section configs (zones handled above) base = self._base_configs.get(cam_name, {}) for section in PROFILE_SECTION_UPDATES: + if section == "zones": + continue base_data = base.get(section) if base_data is None: continue @@ -190,12 +215,23 @@ class ProfileManager: base_zones = self._base_zones.get(cam_name, {}) merged_zones = copy.deepcopy(base_zones) merged_zones.update(profile.zones) + # Profile zone objects are parsed without colors or contours + # (those are set during CameraConfig init / post-validation). + # Inherit the base zone's color when available, and ensure + # every zone has a valid contour for rendering. + for name, zone in merged_zones.items(): + if zone.contour.size == 0: + zone.generate_contour(cam_config.frame_shape) + if zone.color == (0, 0, 0) and name in base_zones: + zone._color = base_zones[name].color cam_config.zones = merged_zones changed.setdefault(cam_name, set()).add("zones") base = self._base_configs.get(cam_name, {}) for section in PROFILE_SECTION_UPDATES: + if section == "zones": + continue profile_section = getattr(profile, section, None) if profile_section is None: continue diff --git a/web/src/views/settings/MasksAndZonesView.tsx b/web/src/views/settings/MasksAndZonesView.tsx index 1b7614a8e..8291a6a82 100644 --- a/web/src/views/settings/MasksAndZonesView.tsx +++ b/web/src/views/settings/MasksAndZonesView.tsx @@ -249,17 +249,26 @@ export default function MasksAndZonesView({ ? cameraConfig.profiles?.[currentEditingProfile] : undefined; + // When a profile is active, the top-level sections contain + // effective (profile-merged) values. Use base_config for the + // original base values so the "Base Config" view is accurate and + // the base layer for profile merging is correct. + const baseMotion = (cameraConfig.base_config?.motion ?? + cameraConfig.motion) as typeof cameraConfig.motion; + const baseObjects = (cameraConfig.base_config?.objects ?? + cameraConfig.objects) as typeof cameraConfig.objects; + const baseZones = (cameraConfig.base_config?.zones ?? + cameraConfig.zones) as typeof cameraConfig.zones; + // Build base zone names set for source tracking - const baseZoneNames = new Set(Object.keys(cameraConfig.zones)); + const baseZoneNames = new Set(Object.keys(baseZones)); const profileZoneNames = new Set(Object.keys(profileData?.zones ?? {})); - const baseMotionMaskNames = new Set( - Object.keys(cameraConfig.motion.mask || {}), - ); + const baseMotionMaskNames = new Set(Object.keys(baseMotion.mask || {})); const profileMotionMaskNames = new Set( Object.keys(profileData?.motion?.mask ?? {}), ); const baseGlobalObjectMaskNames = new Set( - Object.keys(cameraConfig.objects.mask || {}), + Object.keys(baseObjects.mask || {}), ); const profileGlobalObjectMaskNames = new Set( Object.keys(profileData?.objects?.mask ?? {}), @@ -274,7 +283,7 @@ export default function MasksAndZonesView({ } >(); - for (const [name, zoneData] of Object.entries(cameraConfig.zones)) { + for (const [name, zoneData] of Object.entries(baseZones)) { if (currentEditingProfile && profileZoneNames.has(name)) { // Profile overrides this base zone mergedZones.set(name, { @@ -302,7 +311,8 @@ export default function MasksAndZonesView({ const zones: Polygon[] = []; for (const [name, { data: zoneData, source }] of mergedZones) { const isBase = source === "base" && !!currentEditingProfile; - const baseColor = zoneData.color ?? [128, 128, 0]; + const baseColor = + zoneData.color ?? baseZones[name]?.color ?? [128, 128, 0]; zones.push({ type: "zone" as PolygonType, typeIndex: zoneIndex, @@ -339,9 +349,7 @@ export default function MasksAndZonesView({ } >(); - for (const [maskId, maskData] of Object.entries( - cameraConfig.motion.mask || {}, - )) { + for (const [maskId, maskData] of Object.entries(baseMotion.mask || {})) { if (currentEditingProfile && profileMotionMaskNames.has(maskId)) { mergedMotionMasks.set(maskId, { data: profileData!.motion!.mask![maskId], @@ -406,9 +414,7 @@ export default function MasksAndZonesView({ } >(); - for (const [maskId, maskData] of Object.entries( - cameraConfig.objects.mask || {}, - )) { + for (const [maskId, maskData] of Object.entries(baseObjects.mask || {})) { if (currentEditingProfile && profileGlobalObjectMaskNames.has(maskId)) { mergedGlobalObjectMasks.set(maskId, { data: profileData!.objects!.mask![maskId], @@ -472,7 +478,7 @@ export default function MasksAndZonesView({ // Build per-object filter mask names for profile tracking const baseFilterMaskNames = new Set(); for (const [, filterConfig] of Object.entries( - cameraConfig.objects.filters, + baseObjects.filters || {}, )) { for (const maskId of Object.keys(filterConfig.mask || {})) { if (!maskId.startsWith("global_")) { @@ -495,9 +501,7 @@ export default function MasksAndZonesView({ } // Per-object filter masks (base) - const objectMasks: Polygon[] = Object.entries( - cameraConfig.objects.filters, - ) + const objectMasks: Polygon[] = Object.entries(baseObjects.filters || {}) .filter( ([, filterConfig]) => filterConfig.mask && Object.keys(filterConfig.mask).length > 0,