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
This commit is contained in:
Josh Hawkins 2026-03-16 13:14:58 -05:00
parent d41d328a9b
commit 3a08b3d54b
2 changed files with 63 additions and 23 deletions

View File

@ -29,6 +29,7 @@ PROFILE_SECTION_UPDATES: dict[str, CameraConfigUpdateEnum] = {
"record": CameraConfigUpdateEnum.record, "record": CameraConfigUpdateEnum.record,
"review": CameraConfigUpdateEnum.review, "review": CameraConfigUpdateEnum.review,
"snapshots": CameraConfigUpdateEnum.snapshots, "snapshots": CameraConfigUpdateEnum.snapshots,
"zones": CameraConfigUpdateEnum.zones,
} }
PERSISTENCE_FILE = Path(CONFIG_DIR) / ".active_profile" PERSISTENCE_FILE = Path(CONFIG_DIR) / ".active_profile"
@ -60,14 +61,36 @@ class ProfileManager:
self._base_enabled[cam_name] = cam_config.enabled self._base_enabled[cam_name] = cam_config.enabled
self._base_zones[cam_name] = copy.deepcopy(cam_config.zones) self._base_zones[cam_name] = copy.deepcopy(cam_config.zones)
for section in PROFILE_SECTION_UPDATES: for section in PROFILE_SECTION_UPDATES:
section_config = getattr(cam_config, section, None) section_value = getattr(cam_config, section, None)
if section_config is not 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] = ( self._base_configs[cam_name][section] = (
section_config.model_dump() section_value.model_dump()
) )
self._base_api_configs[cam_name][section] = ( self._base_api_configs[cam_name][section] = (
section_config.model_dump( section_value.model_dump(
mode="json", warnings="none", exclude_none=True mode="json",
warnings="none",
exclude_none=True,
) )
) )
@ -154,9 +177,11 @@ class ProfileManager:
cam_config.zones = copy.deepcopy(base_zones) cam_config.zones = copy.deepcopy(base_zones)
changed.setdefault(cam_name, set()).add("zones") changed.setdefault(cam_name, set()).add("zones")
# Restore section configs # Restore section configs (zones handled above)
base = self._base_configs.get(cam_name, {}) base = self._base_configs.get(cam_name, {})
for section in PROFILE_SECTION_UPDATES: for section in PROFILE_SECTION_UPDATES:
if section == "zones":
continue
base_data = base.get(section) base_data = base.get(section)
if base_data is None: if base_data is None:
continue continue
@ -190,12 +215,23 @@ class ProfileManager:
base_zones = self._base_zones.get(cam_name, {}) base_zones = self._base_zones.get(cam_name, {})
merged_zones = copy.deepcopy(base_zones) merged_zones = copy.deepcopy(base_zones)
merged_zones.update(profile.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 cam_config.zones = merged_zones
changed.setdefault(cam_name, set()).add("zones") changed.setdefault(cam_name, set()).add("zones")
base = self._base_configs.get(cam_name, {}) base = self._base_configs.get(cam_name, {})
for section in PROFILE_SECTION_UPDATES: for section in PROFILE_SECTION_UPDATES:
if section == "zones":
continue
profile_section = getattr(profile, section, None) profile_section = getattr(profile, section, None)
if profile_section is None: if profile_section is None:
continue continue

View File

@ -249,17 +249,26 @@ export default function MasksAndZonesView({
? cameraConfig.profiles?.[currentEditingProfile] ? cameraConfig.profiles?.[currentEditingProfile]
: undefined; : 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 // 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 profileZoneNames = new Set(Object.keys(profileData?.zones ?? {}));
const baseMotionMaskNames = new Set( const baseMotionMaskNames = new Set(Object.keys(baseMotion.mask || {}));
Object.keys(cameraConfig.motion.mask || {}),
);
const profileMotionMaskNames = new Set( const profileMotionMaskNames = new Set(
Object.keys(profileData?.motion?.mask ?? {}), Object.keys(profileData?.motion?.mask ?? {}),
); );
const baseGlobalObjectMaskNames = new Set( const baseGlobalObjectMaskNames = new Set(
Object.keys(cameraConfig.objects.mask || {}), Object.keys(baseObjects.mask || {}),
); );
const profileGlobalObjectMaskNames = new Set( const profileGlobalObjectMaskNames = new Set(
Object.keys(profileData?.objects?.mask ?? {}), 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)) { if (currentEditingProfile && profileZoneNames.has(name)) {
// Profile overrides this base zone // Profile overrides this base zone
mergedZones.set(name, { mergedZones.set(name, {
@ -302,7 +311,8 @@ export default function MasksAndZonesView({
const zones: Polygon[] = []; const zones: Polygon[] = [];
for (const [name, { data: zoneData, source }] of mergedZones) { for (const [name, { data: zoneData, source }] of mergedZones) {
const isBase = source === "base" && !!currentEditingProfile; const isBase = source === "base" && !!currentEditingProfile;
const baseColor = zoneData.color ?? [128, 128, 0]; const baseColor =
zoneData.color ?? baseZones[name]?.color ?? [128, 128, 0];
zones.push({ zones.push({
type: "zone" as PolygonType, type: "zone" as PolygonType,
typeIndex: zoneIndex, typeIndex: zoneIndex,
@ -339,9 +349,7 @@ export default function MasksAndZonesView({
} }
>(); >();
for (const [maskId, maskData] of Object.entries( for (const [maskId, maskData] of Object.entries(baseMotion.mask || {})) {
cameraConfig.motion.mask || {},
)) {
if (currentEditingProfile && profileMotionMaskNames.has(maskId)) { if (currentEditingProfile && profileMotionMaskNames.has(maskId)) {
mergedMotionMasks.set(maskId, { mergedMotionMasks.set(maskId, {
data: profileData!.motion!.mask![maskId], data: profileData!.motion!.mask![maskId],
@ -406,9 +414,7 @@ export default function MasksAndZonesView({
} }
>(); >();
for (const [maskId, maskData] of Object.entries( for (const [maskId, maskData] of Object.entries(baseObjects.mask || {})) {
cameraConfig.objects.mask || {},
)) {
if (currentEditingProfile && profileGlobalObjectMaskNames.has(maskId)) { if (currentEditingProfile && profileGlobalObjectMaskNames.has(maskId)) {
mergedGlobalObjectMasks.set(maskId, { mergedGlobalObjectMasks.set(maskId, {
data: profileData!.objects!.mask![maskId], data: profileData!.objects!.mask![maskId],
@ -472,7 +478,7 @@ export default function MasksAndZonesView({
// Build per-object filter mask names for profile tracking // Build per-object filter mask names for profile tracking
const baseFilterMaskNames = new Set<string>(); const baseFilterMaskNames = new Set<string>();
for (const [, filterConfig] of Object.entries( for (const [, filterConfig] of Object.entries(
cameraConfig.objects.filters, baseObjects.filters || {},
)) { )) {
for (const maskId of Object.keys(filterConfig.mask || {})) { for (const maskId of Object.keys(filterConfig.mask || {})) {
if (!maskId.startsWith("global_")) { if (!maskId.startsWith("global_")) {
@ -495,9 +501,7 @@ export default function MasksAndZonesView({
} }
// Per-object filter masks (base) // Per-object filter masks (base)
const objectMasks: Polygon[] = Object.entries( const objectMasks: Polygon[] = Object.entries(baseObjects.filters || {})
cameraConfig.objects.filters,
)
.filter( .filter(
([, filterConfig]) => ([, filterConfig]) =>
filterConfig.mask && Object.keys(filterConfig.mask).length > 0, filterConfig.mask && Object.keys(filterConfig.mask).length > 0,