This commit is contained in:
Josh Hawkins 2026-01-15 11:43:23 -06:00
parent 35fd1ccbc0
commit 58053eb3f0
6 changed files with 708 additions and 531 deletions

View File

@ -1,21 +1,25 @@
import Heading from "../ui/heading"; import Heading from "../ui/heading";
import { Separator } from "../ui/separator"; import { Separator } from "../ui/separator";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form"; import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { useCallback, useEffect, useMemo } from "react"; import { useCallback, useEffect, useMemo } from "react";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { useForm, FormProvider } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import PolygonEditControls from "./PolygonEditControls"; import PolygonEditControls from "./PolygonEditControls";
import { FaCheckCircle } from "react-icons/fa"; import { FaCheckCircle } from "react-icons/fa";
import { Polygon } from "@/types/canvas"; import { MotionMaskFormValuesType, Polygon } from "@/types/canvas";
import useSWR from "swr"; import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { import { flattenPoints, interpolatePoints } from "@/utils/canvasUtil";
flattenPoints,
interpolatePoints,
parseCoordinates,
} from "@/utils/canvasUtil";
import axios from "axios"; import axios from "axios";
import { toast } from "sonner"; import { toast } from "sonner";
import { Toaster } from "../ui/sonner"; import { Toaster } from "../ui/sonner";
@ -24,6 +28,8 @@ import { Link } from "react-router-dom";
import { LuExternalLink } from "react-icons/lu"; import { LuExternalLink } from "react-icons/lu";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { useDocDomain } from "@/hooks/use-doc-domain"; import { useDocDomain } from "@/hooks/use-doc-domain";
import NameAndIdFields from "../input/NameAndIdFields";
import { Switch } from "../ui/switch";
type MotionMaskEditPaneProps = { type MotionMaskEditPaneProps = {
polygons?: Polygon[]; polygons?: Polygon[];
@ -73,12 +79,24 @@ export default function MotionMaskEditPane({
const defaultName = useMemo(() => { const defaultName = useMemo(() => {
if (!polygons) { if (!polygons) {
return; return "";
} }
const count = polygons.filter((poly) => poly.type == "motion_mask").length; const count = polygons.filter((poly) => poly.type == "motion_mask").length;
return `Motion Mask ${count + 1}`; return t("masksAndZones.motionMasks.defaultName", {
number: count + 1,
});
}, [polygons, t]);
const defaultId = useMemo(() => {
if (!polygons) {
return "";
}
const count = polygons.filter((poly) => poly.type == "motion_mask").length;
return `motion_mask_${count + 1}`;
}, [polygons]); }, [polygons]);
const polygonArea = useMemo(() => { const polygonArea = useMemo(() => {
@ -104,24 +122,52 @@ export default function MotionMaskEditPane({
} }
}, [polygon, scaledWidth, scaledHeight]); }, [polygon, scaledWidth, scaledHeight]);
const formSchema = z const formSchema = z.object({
.object({ name: z
polygon: z.object({ name: z.string(), isFinished: z.boolean() }), .string()
.min(1, {
message: t("masksAndZones.form.id.error.mustNotBeEmpty"),
}) })
.refine(() => polygon?.isFinished === true, { .refine(
(value: string) => {
// When editing, allow the same name
if (polygon?.name && value === polygon.name) {
return true;
}
// Check if mask ID already exists
const existingMaskIds = Object.keys(cameraConfig?.motion.mask || {});
return !existingMaskIds.includes(value);
},
{
message: t("masksAndZones.form.id.error.alreadyExists"),
},
),
friendly_name: z.string().min(1, {
message: t("masksAndZones.form.name.error.mustNotBeEmpty"),
}),
enabled: z.boolean(),
isFinished: z.boolean().refine(() => polygon?.isFinished === true, {
message: t("masksAndZones.form.polygonDrawing.error.mustBeFinished"), message: t("masksAndZones.form.polygonDrawing.error.mustBeFinished"),
path: ["polygon.isFinished"], }),
}); });
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
mode: "onChange", mode: "onChange",
defaultValues: { defaultValues: {
polygon: { isFinished: polygon?.isFinished ?? false, name: defaultName }, name: polygon?.name || defaultId,
friendly_name: polygon?.friendly_name || defaultName,
enabled: polygon?.enabled ?? true,
isFinished: polygon?.isFinished ?? false,
}, },
}); });
const saveToConfig = useCallback(async () => { const saveToConfig = useCallback(
async ({
name: maskId,
friendly_name,
enabled,
}: MotionMaskFormValuesType) => {
if (!scaledWidth || !scaledHeight || !polygon || !cameraConfig) { if (!scaledWidth || !scaledHeight || !polygon || !cameraConfig) {
return; return;
} }
@ -130,49 +176,57 @@ export default function MotionMaskEditPane({
interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1), interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1),
).join(","); ).join(",");
let index = Array.isArray(cameraConfig.motion.mask)
? cameraConfig.motion.mask.length
: cameraConfig.motion.mask
? 1
: 0;
const editingMask = polygon.name.length > 0; const editingMask = polygon.name.length > 0;
const renamingMask = editingMask && maskId !== polygon.name;
// editing existing mask, not creating a new one // Build the new mask configuration
if (editingMask) { const maskConfig = {
index = polygon.typeIndex; friendly_name: friendly_name,
enabled: enabled,
coordinates: coordinates,
};
// If renaming, we need to delete the old mask first
if (renamingMask) {
try {
await axios.put(
`config/set?cameras.${polygon.camera}.motion.mask.${polygon.name}`,
{
requires_restart: 0,
},
);
} catch (error) {
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
position: "top-center",
});
setIsLoading(false);
return;
}
} }
const filteredMask = ( // Save the new/updated mask using JSON body
Array.isArray(cameraConfig.motion.mask)
? cameraConfig.motion.mask
: [cameraConfig.motion.mask]
).filter((_, currentIndex) => currentIndex !== index);
filteredMask.splice(index, 0, coordinates);
const queryString = filteredMask
.map((pointsArray) => {
const coordinates = flattenPoints(parseCoordinates(pointsArray)).join(
",",
);
return `cameras.${polygon?.camera}.motion.mask=${coordinates}&`;
})
.join("");
axios axios
.put(`config/set?${queryString}`, { .put("config/set", {
config_data: {
cameras: {
[polygon.camera]: {
motion: {
mask: {
[maskId]: maskConfig,
},
},
},
},
},
requires_restart: 0, requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/motion`, update_topic: `config/cameras/${polygon.camera}/motion`,
}) })
.then((res) => { .then((res) => {
if (res.status === 200) { if (res.status === 200) {
toast.success( toast.success(
polygon.name t("masksAndZones.motionMasks.toast.success.title", {
? t("masksAndZones.motionMasks.toast.success.title", { polygonName: friendly_name || maskId,
polygonName: polygon.name, }),
})
: t("masksAndZones.motionMasks.toast.success.noName"),
{ {
position: "top-center", position: "top-center",
}, },
@ -205,7 +259,8 @@ export default function MotionMaskEditPane({
.finally(() => { .finally(() => {
setIsLoading(false); setIsLoading(false);
}); });
}, [ },
[
updateConfig, updateConfig,
polygon, polygon,
scaledWidth, scaledWidth,
@ -213,7 +268,8 @@ export default function MotionMaskEditPane({
setIsLoading, setIsLoading,
cameraConfig, cameraConfig,
t, t,
]); ],
);
function onSubmit(values: z.infer<typeof formSchema>) { function onSubmit(values: z.infer<typeof formSchema>) {
if (activePolygonIndex === undefined || !values || !polygons) { if (activePolygonIndex === undefined || !values || !polygons) {
@ -221,7 +277,7 @@ export default function MotionMaskEditPane({
} }
setIsLoading(true); setIsLoading(true);
saveToConfig(); saveToConfig(values as MotionMaskFormValuesType);
if (onSave) { if (onSave) {
onSave(); onSave();
} }
@ -310,23 +366,47 @@ export default function MotionMaskEditPane({
</> </>
)} )}
<FormProvider {...form}>
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-1 flex-col space-y-6" className="flex flex-1 flex-col space-y-6"
> >
<NameAndIdFields
type="motion_mask"
control={form.control}
nameField="friendly_name"
idField="name"
idVisible={(polygon && polygon.name.length > 0) ?? false}
nameLabel={t("masksAndZones.motionMasks.name.title")}
nameDescription={t("masksAndZones.motionMasks.name.description")}
placeholderName={t("masksAndZones.motionMasks.name.placeholder")}
/>
<FormField <FormField
control={form.control} control={form.control}
name="polygon.name" name="enabled"
render={() => ( render={({ field }) => (
<FormItem> <FormItem className="flex flex-row items-center justify-between">
<FormMessage /> <div className="space-y-0.5">
<FormLabel>
{t("masksAndZones.masks.enabled.title")}
</FormLabel>
<FormDescription>
{t("masksAndZones.masks.enabled.description")}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="polygon.isFinished" name="isFinished"
render={() => ( render={() => (
<FormItem> <FormItem>
<FormMessage /> <FormMessage />
@ -362,6 +442,7 @@ export default function MotionMaskEditPane({
</div> </div>
</form> </form>
</Form> </Form>
</FormProvider>
</> </>
); );
} }

View File

@ -23,22 +23,20 @@ import { useCallback, useEffect, useMemo } from "react";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr"; import useSWR from "swr";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { useForm, FormProvider } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { ObjectMaskFormValuesType, Polygon } from "@/types/canvas"; import { ObjectMaskFormValuesType, Polygon } from "@/types/canvas";
import PolygonEditControls from "./PolygonEditControls"; import PolygonEditControls from "./PolygonEditControls";
import { FaCheckCircle } from "react-icons/fa"; import { FaCheckCircle } from "react-icons/fa";
import { import { flattenPoints, interpolatePoints } from "@/utils/canvasUtil";
flattenPoints,
interpolatePoints,
parseCoordinates,
} from "@/utils/canvasUtil";
import axios from "axios"; import axios from "axios";
import { toast } from "sonner"; import { toast } from "sonner";
import { Toaster } from "../ui/sonner"; import { Toaster } from "../ui/sonner";
import ActivityIndicator from "../indicators/activity-indicator"; import ActivityIndicator from "../indicators/activity-indicator";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getTranslatedLabel } from "@/utils/i18n"; import { getTranslatedLabel } from "@/utils/i18n";
import NameAndIdFields from "../input/NameAndIdFields";
import { Switch } from "../ui/switch";
type ObjectMaskEditPaneProps = { type ObjectMaskEditPaneProps = {
polygons?: Polygon[]; polygons?: Polygon[];
@ -87,7 +85,7 @@ export default function ObjectMaskEditPane({
const defaultName = useMemo(() => { const defaultName = useMemo(() => {
if (!polygons) { if (!polygons) {
return; return "";
} }
const count = polygons.filter((poly) => poly.type == "object_mask").length; const count = polygons.filter((poly) => poly.type == "object_mask").length;
@ -95,40 +93,81 @@ export default function ObjectMaskEditPane({
let objectType = ""; let objectType = "";
const objects = polygon?.objects[0]; const objects = polygon?.objects[0];
if (objects === undefined) { if (objects === undefined) {
objectType = "all objects"; objectType = t("masksAndZones.zones.allObjects");
} else { } else {
objectType = objects; objectType = getTranslatedLabel(objects);
} }
return t("masksAndZones.objectMaskLabel", { return t("masksAndZones.objectMaskLabel", {
number: count + 1, number: count + 1,
label: getTranslatedLabel(objectType), label: objectType,
}); });
}, [polygons, polygon, t]); }, [polygons, polygon, t]);
const formSchema = z const defaultId = useMemo(() => {
.object({ if (!polygons) {
objects: z.string(), return "";
polygon: z.object({ isFinished: z.boolean(), name: z.string() }), }
const count = polygons.filter((poly) => poly.type == "object_mask").length;
return `object_mask_${count + 1}`;
}, [polygons]);
const formSchema = z.object({
name: z
.string()
.min(1, {
message: t("masksAndZones.form.id.error.mustNotBeEmpty"),
}) })
.refine(() => polygon?.isFinished === true, { .refine(
(value: string) => {
// When editing, allow the same name
if (polygon?.name && value === polygon.name) {
return true;
}
// Check if mask ID already exists in global masks or filter masks
const globalMaskIds = Object.keys(cameraConfig?.objects.mask || {});
const filterMaskIds = Object.values(
cameraConfig?.objects.filters || {},
).flatMap((filter) => Object.keys(filter.mask || {}));
return (
!globalMaskIds.includes(value) && !filterMaskIds.includes(value)
);
},
{
message: t("masksAndZones.form.id.error.alreadyExists"),
},
),
friendly_name: z.string().min(1, {
message: t("masksAndZones.form.name.error.mustNotBeEmpty"),
}),
enabled: z.boolean(),
objects: z.string(),
isFinished: z.boolean().refine(() => polygon?.isFinished === true, {
message: t("masksAndZones.form.polygonDrawing.error.mustBeFinished"), message: t("masksAndZones.form.polygonDrawing.error.mustBeFinished"),
path: ["polygon.isFinished"], }),
}); });
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
mode: "onChange", mode: "onChange",
defaultValues: { defaultValues: {
name: polygon?.name || defaultId,
friendly_name: polygon?.friendly_name || defaultName,
enabled: polygon?.enabled ?? true,
objects: polygon?.objects[0] ?? "all_labels", objects: polygon?.objects[0] ?? "all_labels",
polygon: { isFinished: polygon?.isFinished ?? false, name: defaultName }, isFinished: polygon?.isFinished ?? false,
}, },
}); });
const saveToConfig = useCallback( const saveToConfig = useCallback(
async ( async ({
{ objects: form_objects }: ObjectMaskFormValuesType, // values submitted via the form name: maskId,
) => { friendly_name,
enabled,
objects: form_objects,
}: ObjectMaskFormValuesType) => {
if (!scaledWidth || !scaledHeight || !polygon || !cameraConfig) { if (!scaledWidth || !scaledHeight || !polygon || !cameraConfig) {
return; return;
} }
@ -137,88 +176,87 @@ export default function ObjectMaskEditPane({
interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1), interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1),
).join(","); ).join(",");
let queryString = "";
let configObject;
let createFilter = false;
let globalMask = false;
let filteredMask = [coordinates];
const editingMask = polygon.name.length > 0; const editingMask = polygon.name.length > 0;
const renamingMask = editingMask && maskId !== polygon.name;
const globalMask = form_objects === "all_labels";
// global mask on camera for all objects // Build the mask configuration
if (form_objects == "all_labels") { const maskConfig = {
configObject = cameraConfig.objects.mask; friendly_name: friendly_name,
globalMask = true; enabled: enabled,
} else { coordinates: coordinates,
if ( };
cameraConfig.objects.filters[form_objects] &&
cameraConfig.objects.filters[form_objects].mask !== null
) {
configObject = cameraConfig.objects.filters[form_objects].mask;
} else {
createFilter = true;
}
}
if (!createFilter) { // If renaming, delete the old mask first
let index = Array.isArray(configObject) if (renamingMask) {
? configObject.length try {
: configObject // Determine if old mask was global or per-object
? 1 const wasGlobal =
: 0; polygon.objects.length === 0 || polygon.objects[0] === "all_labels";
const oldPath = wasGlobal
? `cameras.${polygon.camera}.objects.mask.${polygon.name}`
: `cameras.${polygon.camera}.objects.filters.${polygon.objects[0]}.mask.${polygon.name}`;
// editing existing mask, not creating a new one await axios.put(`config/set?${oldPath}`, {
if (editingMask) { requires_restart: 0,
index = polygon.typeIndex; });
} } catch (error) {
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
filteredMask = ( position: "top-center",
Array.isArray(configObject) ? configObject : [configObject as string] });
).filter((_, currentIndex) => currentIndex !== index); setIsLoading(false);
filteredMask.splice(index, 0, coordinates);
}
// prevent duplicating global masks under specific object filters
if (!globalMask) {
const globalObjectMasksArray = Array.isArray(cameraConfig.objects.mask)
? cameraConfig.objects.mask
: cameraConfig.objects.mask
? [cameraConfig.objects.mask]
: [];
filteredMask = filteredMask.filter(
(mask) => !globalObjectMasksArray.includes(mask),
);
}
queryString = filteredMask
.map((pointsArray) => {
const coordinates = flattenPoints(parseCoordinates(pointsArray)).join(
",",
);
return globalMask
? `cameras.${polygon?.camera}.objects.mask=${coordinates}&`
: `cameras.${polygon?.camera}.objects.filters.${form_objects}.mask=${coordinates}&`;
})
.join("");
if (!queryString) {
return; return;
} }
}
// Build the config structure based on whether it's global or per-object
let configBody;
if (globalMask) {
configBody = {
config_data: {
cameras: {
[polygon.camera]: {
objects: {
mask: {
[maskId]: maskConfig,
},
},
},
},
},
requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/objects`,
};
} else {
configBody = {
config_data: {
cameras: {
[polygon.camera]: {
objects: {
filters: {
[form_objects]: {
mask: {
[maskId]: maskConfig,
},
},
},
},
},
},
},
requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/objects`,
};
}
axios axios
.put(`config/set?${queryString}`, { .put("config/set", configBody)
requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/objects`,
})
.then((res) => { .then((res) => {
if (res.status === 200) { if (res.status === 200) {
toast.success( toast.success(
polygon.name t("masksAndZones.objectMasks.toast.success.title", {
? t("masksAndZones.objectMasks.toast.success.title", { polygonName: friendly_name || maskId,
polygonName: polygon.name, }),
})
: t("masksAndZones.objectMasks.toast.success.noName"),
{ {
position: "top-center", position: "top-center",
}, },
@ -323,18 +361,46 @@ export default function ObjectMaskEditPane({
<Separator className="my-3 bg-secondary" /> <Separator className="my-3 bg-secondary" />
<FormProvider {...form}>
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-1 flex-col space-y-6" className="flex flex-1 flex-col space-y-6"
> >
<div> <div className="space-y-4">
<NameAndIdFields
type="object_mask"
control={form.control}
nameField="friendly_name"
idField="name"
idVisible={(polygon && polygon.name.length > 0) ?? false}
nameLabel={t("masksAndZones.objectMasks.name.title")}
nameDescription={t(
"masksAndZones.objectMasks.name.description",
)}
placeholderName={t(
"masksAndZones.objectMasks.name.placeholder",
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="polygon.name" name="enabled"
render={() => ( render={({ field }) => (
<FormItem> <FormItem className="flex flex-row items-center justify-between">
<FormMessage /> <div className="space-y-0.5">
<FormLabel>
{t("masksAndZones.masks.enabled.title")}
</FormLabel>
<FormDescription>
{t("masksAndZones.masks.enabled.description")}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem> </FormItem>
)} )}
/> />
@ -369,7 +435,7 @@ export default function ObjectMaskEditPane({
/> />
<FormField <FormField
control={form.control} control={form.control}
name="polygon.isFinished" name="isFinished"
render={() => ( render={() => (
<FormItem> <FormItem>
<FormMessage /> <FormMessage />
@ -406,6 +472,7 @@ export default function ObjectMaskEditPane({
</div> </div>
</form> </form>
</Form> </Form>
</FormProvider>
</> </>
); );
} }

View File

@ -20,11 +20,7 @@ import { FaDrawPolygon, FaObjectGroup } from "react-icons/fa";
import { BsPersonBoundingBox } from "react-icons/bs"; import { BsPersonBoundingBox } from "react-icons/bs";
import { HiOutlineDotsVertical, HiTrash } from "react-icons/hi"; import { HiOutlineDotsVertical, HiTrash } from "react-icons/hi";
import { isDesktop, isMobile } from "react-device-detect"; import { isDesktop, isMobile } from "react-device-detect";
import { import { toRGBColorString } from "@/utils/canvasUtil";
flattenPoints,
parseCoordinates,
toRGBColorString,
} from "@/utils/canvasUtil";
import { Polygon, PolygonType } from "@/types/canvas"; import { Polygon, PolygonType } from "@/types/canvas";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import axios from "axios"; import axios from "axios";
@ -81,93 +77,6 @@ export default function PolygonItem({
if (!polygon || !cameraConfig) { if (!polygon || !cameraConfig) {
return; return;
} }
let url = "";
if (polygon.type == "zone") {
const { alertQueries, detectionQueries } = reviewQueries(
polygon.name,
false,
false,
polygon.camera,
cameraConfig?.review.alerts.required_zones || [],
cameraConfig?.review.detections.required_zones || [],
);
url = `cameras.${polygon.camera}.zones.${polygon.name}${alertQueries}${detectionQueries}`;
}
if (polygon.type == "motion_mask") {
const filteredMask = (
Array.isArray(cameraConfig.motion.mask)
? cameraConfig.motion.mask
: [cameraConfig.motion.mask]
).filter((_, currentIndex) => currentIndex !== polygon.typeIndex);
url = filteredMask
.map((pointsArray) => {
const coordinates = flattenPoints(
parseCoordinates(pointsArray),
).join(",");
return `cameras.${polygon?.camera}.motion.mask=${coordinates}&`;
})
.join("");
if (!url) {
// deleting last mask
url = `cameras.${polygon?.camera}.motion.mask&`;
}
}
if (polygon.type == "object_mask") {
let configObject;
let globalMask = false;
// global mask on camera for all objects
if (!polygon.objects.length) {
configObject = cameraConfig.objects.mask;
globalMask = true;
} else {
configObject = cameraConfig.objects.filters[polygon.objects[0]].mask;
}
if (!configObject) {
return;
}
const globalObjectMasksArray = Array.isArray(cameraConfig.objects.mask)
? cameraConfig.objects.mask
: cameraConfig.objects.mask
? [cameraConfig.objects.mask]
: [];
let filteredMask;
if (globalMask) {
filteredMask = (
Array.isArray(configObject) ? configObject : [configObject]
).filter((_, currentIndex) => currentIndex !== polygon.typeIndex);
} else {
filteredMask = (
Array.isArray(configObject) ? configObject : [configObject]
)
.filter((mask) => !globalObjectMasksArray.includes(mask))
.filter((_, currentIndex) => currentIndex !== polygon.typeIndex);
}
url = filteredMask
.map((pointsArray) => {
const coordinates = flattenPoints(
parseCoordinates(pointsArray),
).join(",");
return globalMask
? `cameras.${polygon?.camera}.objects.mask=${coordinates}&`
: `cameras.${polygon?.camera}.objects.filters.${polygon.objects[0]}.mask=${coordinates}&`;
})
.join("");
if (!url) {
// deleting last mask
url = globalMask
? `cameras.${polygon?.camera}.objects.mask&`
: `cameras.${polygon?.camera}.objects.filters.${polygon.objects[0]}.mask`;
}
}
const updateTopicType = const updateTopicType =
polygon.type === "zone" polygon.type === "zone"
@ -180,6 +89,18 @@ export default function PolygonItem({
setIsLoading(true); setIsLoading(true);
if (polygon.type === "zone") {
// Zones use query string format
const { alertQueries, detectionQueries } = reviewQueries(
polygon.name,
false,
false,
polygon.camera,
cameraConfig?.review.alerts.required_zones || [],
cameraConfig?.review.detections.required_zones || [],
);
const url = `cameras.${polygon.camera}.zones.${polygon.name}${alertQueries}${detectionQueries}`;
await axios await axios
.put(`config/set?${url}`, { .put(`config/set?${url}`, {
requires_restart: 0, requires_restart: 0,
@ -191,9 +112,7 @@ export default function PolygonItem({
t("masksAndZones.form.polygonDrawing.delete.success", { t("masksAndZones.form.polygonDrawing.delete.success", {
name: polygon?.friendly_name ?? polygon?.name, name: polygon?.friendly_name ?? polygon?.name,
}), }),
{ { position: "top-center" },
position: "top-center",
},
); );
updateConfig(); updateConfig();
} else { } else {
@ -202,9 +121,7 @@ export default function PolygonItem({
ns: "common", ns: "common",
errorMessage: res.statusText, errorMessage: res.statusText,
}), }),
{ { position: "top-center" },
position: "top-center",
},
); );
} }
}) })
@ -215,9 +132,102 @@ export default function PolygonItem({
"Unknown error"; "Unknown error";
toast.error( toast.error(
t("toast.save.error.title", { errorMessage, ns: "common" }), t("toast.save.error.title", { errorMessage, ns: "common" }),
{ { position: "top-center" },
position: "top-center", );
})
.finally(() => {
setIsLoading(false);
});
return;
}
// Motion masks and object masks use JSON body format
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let configUpdate: any = {};
if (polygon.type === "motion_mask") {
// Delete mask from motion.mask dict by setting it to undefined
configUpdate = {
cameras: {
[polygon.camera]: {
motion: {
mask: {
[polygon.name]: null, // Setting to null will delete the key
}, },
},
},
},
};
}
if (polygon.type === "object_mask") {
// Determine if this is a global mask or object-specific mask
const isGlobalMask = !polygon.objects.length;
if (isGlobalMask) {
configUpdate = {
cameras: {
[polygon.camera]: {
objects: {
mask: {
[polygon.name]: null, // Setting to null will delete the key
},
},
},
},
};
} else {
configUpdate = {
cameras: {
[polygon.camera]: {
objects: {
filters: {
[polygon.objects[0]]: {
mask: {
[polygon.name]: null, // Setting to null will delete the key
},
},
},
},
},
},
};
}
}
await axios
.put("config/set", {
config_data: configUpdate,
requires_restart: 0,
update_topic: `config/cameras/${polygon.camera}/${updateTopicType}`,
})
.then((res) => {
if (res.status === 200) {
toast.success(
t("masksAndZones.form.polygonDrawing.delete.success", {
name: polygon?.friendly_name ?? polygon?.name,
}),
{ position: "top-center" },
);
updateConfig();
} else {
toast.error(
t("toast.save.error.title", {
ns: "common",
errorMessage: res.statusText,
}),
{ position: "top-center" },
);
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("toast.save.error.title", { errorMessage, ns: "common" }),
{ position: "top-center" },
); );
}) })
.finally(() => { .finally(() => {
@ -238,7 +248,9 @@ export default function PolygonItem({
<div <div
key={index} key={index}
className="transition-background my-1.5 flex flex-row items-center justify-between rounded-lg p-1 duration-100" className={`transition-background my-1.5 flex flex-row items-center justify-between rounded-lg p-1 duration-100 ${
polygon.enabled === false ? "opacity-50" : ""
}`}
data-index={index} data-index={index}
onMouseEnter={() => setHoveredPolygonIndex(index)} onMouseEnter={() => setHoveredPolygonIndex(index)}
onMouseLeave={() => setHoveredPolygonIndex(null)} onMouseLeave={() => setHoveredPolygonIndex(null)}
@ -265,8 +277,11 @@ export default function PolygonItem({
}} }}
/> />
)} )}
<p className="cursor-default"> <p
className={`cursor-default ${polygon.enabled === false ? "line-through" : ""}`}
>
{polygon.friendly_name ?? polygon.name} {polygon.friendly_name ?? polygon.name}
{polygon.enabled === false && " (disabled)"}
</p> </p>
</div> </div>
<AlertDialog <AlertDialog

View File

@ -12,6 +12,7 @@ export type Polygon = {
isFinished: boolean; isFinished: boolean;
color: number[]; color: number[];
friendly_name?: string; friendly_name?: string;
enabled?: boolean;
}; };
export type ZoneFormValuesType = { export type ZoneFormValuesType = {
@ -29,10 +30,17 @@ export type ZoneFormValuesType = {
speed_threshold: number; speed_threshold: number;
}; };
export type ObjectMaskFormValuesType = { export type MotionMaskFormValuesType = {
objects: string;
polygon: {
isFinished: boolean;
name: string; name: string;
}; friendly_name: string;
enabled: boolean;
isFinished: boolean;
};
export type ObjectMaskFormValuesType = {
name: string;
friendly_name: string;
enabled: boolean;
objects: string;
isFinished: boolean;
}; };

View File

@ -106,7 +106,13 @@ export interface CameraConfig {
frame_height: number; frame_height: number;
improve_contrast: boolean; improve_contrast: boolean;
lightning_threshold: number; lightning_threshold: number;
mask: string[]; mask: {
[maskId: string]: {
friendly_name?: string;
enabled: boolean;
coordinates: string;
};
};
mqtt_off_delay: number; mqtt_off_delay: number;
threshold: number; threshold: number;
}; };
@ -128,7 +134,13 @@ export interface CameraConfig {
objects: { objects: {
filters: { filters: {
[objectName: string]: { [objectName: string]: {
mask: string[] | null; mask: {
[maskId: string]: {
friendly_name?: string;
enabled: boolean;
coordinates: string;
};
};
max_area: number; max_area: number;
max_ratio: number; max_ratio: number;
min_area: number; min_area: number;
@ -137,7 +149,13 @@ export interface CameraConfig {
threshold: number; threshold: number;
}; };
}; };
mask: string; mask: {
[maskId: string]: {
friendly_name?: string;
enabled: boolean;
coordinates: string;
};
};
track: string[]; track: string[];
genai: { genai: {
enabled: boolean; enabled: boolean;

View File

@ -34,7 +34,6 @@ import { useSearchEffect } from "@/hooks/use-overlay-state";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useDocDomain } from "@/hooks/use-doc-domain"; import { useDocDomain } from "@/hooks/use-doc-domain";
import { getTranslatedLabel } from "@/utils/i18n";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type MasksAndZoneViewProps = { type MasksAndZoneViewProps = {
@ -250,23 +249,18 @@ export default function MasksAndZonesView({
let globalObjectMasks: Polygon[] = []; let globalObjectMasks: Polygon[] = [];
let objectMasks: Polygon[] = []; let objectMasks: Polygon[] = [];
// this can be an array or a string // Motion masks are a dict with mask_id as key
motionMasks = ( motionMasks = Object.entries(cameraConfig.motion.mask || {}).map(
Array.isArray(cameraConfig.motion.mask) ([maskId, maskData], index) => ({
? cameraConfig.motion.mask
: cameraConfig.motion.mask
? [cameraConfig.motion.mask]
: []
).map((maskData, index) => ({
type: "motion_mask" as PolygonType, type: "motion_mask" as PolygonType,
typeIndex: index, typeIndex: index,
camera: cameraConfig.name, camera: cameraConfig.name,
name: t("masksAndZones.motionMaskLabel", { name: maskId,
number: index + 1, friendly_name: maskData.friendly_name,
}), enabled: maskData.enabled,
objects: [], objects: [],
points: interpolatePoints( points: interpolatePoints(
parseCoordinates(maskData), parseCoordinates(maskData.coordinates),
1, 1,
1, 1,
scaledWidth, scaledWidth,
@ -274,26 +268,22 @@ export default function MasksAndZonesView({
), ),
distances: [], distances: [],
isFinished: true, isFinished: true,
color: [0, 0, 255], color: maskData.enabled ? [0, 0, 255] : [100, 100, 100],
})); }),
);
const globalObjectMasksArray = Array.isArray(cameraConfig.objects.mask) // Global object masks are a dict with mask_id as key
? cameraConfig.objects.mask globalObjectMasks = Object.entries(cameraConfig.objects.mask || {}).map(
: cameraConfig.objects.mask ([maskId, maskData], index) => ({
? [cameraConfig.objects.mask]
: [];
globalObjectMasks = globalObjectMasksArray.map((maskData, index) => ({
type: "object_mask" as PolygonType, type: "object_mask" as PolygonType,
typeIndex: index, typeIndex: index,
camera: cameraConfig.name, camera: cameraConfig.name,
name: t("masksAndZones.objectMaskLabel", { name: maskId,
number: index + 1, friendly_name: maskData.friendly_name,
label: t("masksAndZones.zones.allObjects"), enabled: maskData.enabled,
}),
objects: [], objects: [],
points: interpolatePoints( points: interpolatePoints(
parseCoordinates(maskData), parseCoordinates(maskData.coordinates),
1, 1,
1, 1,
scaledWidth, scaledWidth,
@ -301,29 +291,36 @@ export default function MasksAndZonesView({
), ),
distances: [], distances: [],
isFinished: true, isFinished: true,
color: [128, 128, 128], color: maskData.enabled ? [128, 128, 128] : [80, 80, 80],
})); }),
);
const globalObjectMasksCount = globalObjectMasks.length; const globalObjectMaskIds = Object.keys(cameraConfig.objects.mask || {});
let index = 0; let objectMaskIndex = globalObjectMasks.length;
objectMasks = Object.entries(cameraConfig.objects.filters) objectMasks = Object.entries(cameraConfig.objects.filters)
.filter(([, { mask }]) => mask || Array.isArray(mask)) .filter(
.flatMap(([objectName, { mask }]): Polygon[] => { ([, filterConfig]) =>
const maskArray = Array.isArray(mask) ? mask : mask ? [mask] : []; filterConfig.mask && Object.keys(filterConfig.mask).length > 0,
return maskArray.flatMap((maskItem, subIndex) => { )
const maskItemString = maskItem; .flatMap(([objectName, filterConfig]): Polygon[] => {
return Object.entries(filterConfig.mask || {}).flatMap(
([maskId, maskData]) => {
// Skip if this mask is already included in global masks
if (globalObjectMaskIds.includes(maskId)) {
return [];
}
const newMask = { const newMask = {
type: "object_mask" as PolygonType, type: "object_mask" as PolygonType,
typeIndex: subIndex, typeIndex: objectMaskIndex,
camera: cameraConfig.name, camera: cameraConfig.name,
name: t("masksAndZones.objectMaskLabel", { name: maskId,
number: globalObjectMasksCount + index + 1, friendly_name: maskData.friendly_name,
label: getTranslatedLabel(objectName), enabled: maskData.enabled,
}),
objects: [objectName], objects: [objectName],
points: interpolatePoints( points: interpolatePoints(
parseCoordinates(maskItem), parseCoordinates(maskData.coordinates),
1, 1,
1, 1,
scaledWidth, scaledWidth,
@ -331,21 +328,12 @@ export default function MasksAndZonesView({
), ),
distances: [], distances: [],
isFinished: true, isFinished: true,
color: [128, 128, 128], color: maskData.enabled ? [128, 128, 128] : [80, 80, 80],
}; };
index++; objectMaskIndex++;
if (
globalObjectMasksArray.some(
(globalMask) => globalMask === maskItemString,
)
) {
index--;
return [];
} else {
return [newMask]; return [newMask];
} },
}); );
}); });
setAllPolygons([ setAllPolygons([