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,
"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

View File

@ -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<string>();
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,