Masks and zones improvements (#22163)

* migrator and runtime config changes

* component changes to use rasterized_mask

* frontend

* convert none to empty string for config save

* i18n

* update tests

* add enabled config to zones

* zones frontend

* i18n

* docs

* tweaks

* use dashed stroke to indicate disabled

* allow toggle from icon

* use filelock to ensure atomic config updates from endpoint

* enforce atomic config update in the frontend

* toggle via mqtt

* fix global object masks

* correctly handle global object masks in dispatcher

* ws hooks

* render masks and zones based on ws enabled state

* use enabled_in_config for zones and masks

* frontend for enabled_in_config

* tweaks

* i18n

* publish websocket on config save

* i18n tweaks

* pydantic title and description

* i18n generation

* tweaks

* fix typing
This commit is contained in:
Josh Hawkins
2026-02-28 07:04:43 -07:00
committed by GitHub
parent fa1f9a1fa4
commit 6a21b2952d
37 changed files with 1964 additions and 739 deletions
+97 -89
View File
@@ -34,7 +34,6 @@ import { useSearchEffect } from "@/hooks/use-overlay-state";
import { useTranslation } from "react-i18next";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { getTranslatedLabel } from "@/utils/i18n";
import { cn } from "@/lib/utils";
type MasksAndZoneViewProps = {
@@ -54,6 +53,9 @@ export default function MasksAndZonesView({
const [allPolygons, setAllPolygons] = useState<Polygon[]>([]);
const [editingPolygons, setEditingPolygons] = useState<Polygon[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [loadingPolygonIndex, setLoadingPolygonIndex] = useState<
number | undefined
>(undefined);
const [activePolygonIndex, setActivePolygonIndex] = useState<
number | undefined
>(undefined);
@@ -170,6 +172,7 @@ export default function MasksAndZonesView({
objects: [],
camera: selectedCamera,
color: polygonColor,
enabled: true,
},
]);
};
@@ -231,6 +234,8 @@ export default function MasksAndZonesView({
camera: cameraConfig.name,
name,
friendly_name: zoneData.friendly_name,
enabled: zoneData.enabled,
enabled_in_config: zoneData.enabled_in_config,
objects: zoneData.objects,
points: interpolatePoints(
parseCoordinates(zoneData.coordinates),
@@ -250,102 +255,93 @@ export default function MasksAndZonesView({
let globalObjectMasks: Polygon[] = [];
let objectMasks: Polygon[] = [];
// this can be an array or a string
motionMasks = (
Array.isArray(cameraConfig.motion.mask)
? cameraConfig.motion.mask
: cameraConfig.motion.mask
? [cameraConfig.motion.mask]
: []
).map((maskData, index) => ({
type: "motion_mask" as PolygonType,
typeIndex: index,
camera: cameraConfig.name,
name: t("masksAndZones.motionMaskLabel", {
number: index + 1,
// Motion masks are a dict with mask_id as key
motionMasks = Object.entries(cameraConfig.motion.mask || {}).map(
([maskId, maskData], index) => ({
type: "motion_mask" as PolygonType,
typeIndex: index,
camera: cameraConfig.name,
name: maskId,
friendly_name: maskData.friendly_name,
enabled: maskData.enabled,
enabled_in_config: maskData.enabled_in_config,
objects: [],
points: interpolatePoints(
parseCoordinates(maskData.coordinates),
1,
1,
scaledWidth,
scaledHeight,
),
distances: [],
isFinished: true,
color: [0, 0, 255],
}),
objects: [],
points: interpolatePoints(
parseCoordinates(maskData),
1,
1,
scaledWidth,
scaledHeight,
),
distances: [],
isFinished: true,
color: [0, 0, 255],
}));
);
const globalObjectMasksArray = Array.isArray(cameraConfig.objects.mask)
? cameraConfig.objects.mask
: cameraConfig.objects.mask
? [cameraConfig.objects.mask]
: [];
globalObjectMasks = globalObjectMasksArray.map((maskData, index) => ({
type: "object_mask" as PolygonType,
typeIndex: index,
camera: cameraConfig.name,
name: t("masksAndZones.objectMaskLabel", {
number: index + 1,
label: t("masksAndZones.zones.allObjects"),
// Global object masks are a dict with mask_id as key
globalObjectMasks = Object.entries(cameraConfig.objects.mask || {}).map(
([maskId, maskData], index) => ({
type: "object_mask" as PolygonType,
typeIndex: index,
camera: cameraConfig.name,
name: maskId,
friendly_name: maskData.friendly_name,
enabled: maskData.enabled,
enabled_in_config: maskData.enabled_in_config,
objects: [],
points: interpolatePoints(
parseCoordinates(maskData.coordinates),
1,
1,
scaledWidth,
scaledHeight,
),
distances: [],
isFinished: true,
color: [128, 128, 128],
}),
objects: [],
points: interpolatePoints(
parseCoordinates(maskData),
1,
1,
scaledWidth,
scaledHeight,
),
distances: [],
isFinished: true,
color: [128, 128, 128],
}));
);
const globalObjectMasksCount = globalObjectMasks.length;
let index = 0;
let objectMaskIndex = globalObjectMasks.length;
objectMasks = Object.entries(cameraConfig.objects.filters)
.filter(([, { mask }]) => mask || Array.isArray(mask))
.flatMap(([objectName, { mask }]): Polygon[] => {
const maskArray = Array.isArray(mask) ? mask : mask ? [mask] : [];
return maskArray.flatMap((maskItem, subIndex) => {
const maskItemString = maskItem;
const newMask = {
type: "object_mask" as PolygonType,
typeIndex: subIndex,
camera: cameraConfig.name,
name: t("masksAndZones.objectMaskLabel", {
number: globalObjectMasksCount + index + 1,
label: getTranslatedLabel(objectName),
}),
objects: [objectName],
points: interpolatePoints(
parseCoordinates(maskItem),
1,
1,
scaledWidth,
scaledHeight,
),
distances: [],
isFinished: true,
color: [128, 128, 128],
};
index++;
.filter(
([, filterConfig]) =>
filterConfig.mask && Object.keys(filterConfig.mask).length > 0,
)
.flatMap(([objectName, filterConfig]): Polygon[] => {
return Object.entries(filterConfig.mask || {}).flatMap(
([maskId, maskData]) => {
// Skip if this mask is a global mask (prefixed with "global_")
if (maskId.startsWith("global_")) {
return [];
}
if (
globalObjectMasksArray.some(
(globalMask) => globalMask === maskItemString,
)
) {
index--;
return [];
} else {
const newMask = {
type: "object_mask" as PolygonType,
typeIndex: objectMaskIndex,
camera: cameraConfig.name,
name: maskId,
friendly_name: maskData.friendly_name,
enabled: maskData.enabled,
enabled_in_config: maskData.enabled_in_config,
objects: [objectName],
points: interpolatePoints(
parseCoordinates(maskData.coordinates),
1,
1,
scaledWidth,
scaledHeight,
),
distances: [],
isFinished: true,
color: [128, 128, 128],
};
objectMaskIndex++;
return [newMask];
}
});
},
);
});
setAllPolygons([
@@ -548,6 +544,10 @@ export default function MasksAndZonesView({
setActivePolygonIndex={setActivePolygonIndex}
setEditPane={setEditPane}
handleCopyCoordinates={handleCopyCoordinates}
isLoading={isLoading}
setIsLoading={setIsLoading}
loadingPolygonIndex={loadingPolygonIndex}
setLoadingPolygonIndex={setLoadingPolygonIndex}
/>
))}
</div>
@@ -618,6 +618,10 @@ export default function MasksAndZonesView({
setActivePolygonIndex={setActivePolygonIndex}
setEditPane={setEditPane}
handleCopyCoordinates={handleCopyCoordinates}
isLoading={isLoading}
setIsLoading={setIsLoading}
loadingPolygonIndex={loadingPolygonIndex}
setLoadingPolygonIndex={setLoadingPolygonIndex}
/>
))}
</div>
@@ -688,6 +692,10 @@ export default function MasksAndZonesView({
setActivePolygonIndex={setActivePolygonIndex}
setEditPane={setEditPane}
handleCopyCoordinates={handleCopyCoordinates}
isLoading={isLoading}
setIsLoading={setIsLoading}
loadingPolygonIndex={loadingPolygonIndex}
setLoadingPolygonIndex={setLoadingPolygonIndex}
/>
))}
</div>