From 1982fa3461eb16c90610405fd48fdfebfadabe25 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Tue, 16 Apr 2024 10:24:20 -0500 Subject: [PATCH] working zone edit pane --- frigate/config.py | 24 + web/src/components/settings/MasksAndZones.tsx | 47 +- .../settings/MotionMaskEditPane.tsx | 83 ++- .../settings/ObjectMaskEditPane.tsx | 131 +++-- web/src/components/settings/PolygonCanvas.tsx | 6 +- web/src/components/settings/PolygonItem.tsx | 327 ++++++----- web/src/components/settings/ZoneEditPane.tsx | 520 +++++++++++++----- web/src/types/canvas.ts | 2 +- web/src/types/frigateConfig.ts | 8 + web/src/utils/canvasUtil.ts | 4 + web/src/utils/zoneEdutUtil.ts | 94 ++++ 11 files changed, 879 insertions(+), 367 deletions(-) create mode 100644 web/src/utils/zoneEdutUtil.ts diff --git a/frigate/config.py b/frigate/config.py index 1ac7ac886..b7dcd8b95 100644 --- a/frigate/config.py +++ b/frigate/config.py @@ -587,6 +587,14 @@ class ZoneConfig(BaseModel): def contour(self) -> np.ndarray: return self._contour + @field_validator("objects", mode="before") + @classmethod + def validate_objects(cls, v): + if isinstance(v, str) and "," not in v: + return [v] + + return v + def __init__(self, **config): super().__init__(**config) @@ -667,6 +675,14 @@ class AlertsConfig(FrigateBaseModel): title="List of required zones to be entered in order to save the event as an alert.", ) + @field_validator("required_zones", mode="before") + @classmethod + def validate_required_zones(cls, v): + if isinstance(v, str) and "," not in v: + return [v] + + return v + class DetectionsConfig(FrigateBaseModel): """Configure detections""" @@ -679,6 +695,14 @@ class DetectionsConfig(FrigateBaseModel): title="List of required zones to be entered in order to save the event as a detection.", ) + @field_validator("required_zones", mode="before") + @classmethod + def validate_required_zones(cls, v): + if isinstance(v, str) and "," not in v: + return [v] + + return v + class ReviewConfig(FrigateBaseModel): """Configure reviews""" diff --git a/web/src/components/settings/MasksAndZones.tsx b/web/src/components/settings/MasksAndZones.tsx index be33560e4..373eaf5ef 100644 --- a/web/src/components/settings/MasksAndZones.tsx +++ b/web/src/components/settings/MasksAndZones.tsx @@ -64,6 +64,7 @@ export default function MasksAndZones({ const { data: config } = useSWR("config"); const [allPolygons, setAllPolygons] = useState([]); const [editingPolygons, setEditingPolygons] = useState([]); + const [isLoading, setIsLoading] = useState(false); // const [zoneObjects, setZoneObjects] = useState([]); const [activePolygonIndex, setActivePolygonIndex] = useState< number | undefined @@ -219,31 +220,24 @@ export default function MasksAndZones({ } setActivePolygonIndex(allPolygons.length); - let polygonName = ""; + let polygonColor = [128, 128, 0]; if (type == "motion_mask") { - const count = allPolygons.filter( - (poly) => poly.type == "motion_mask", - ).length; - polygonName = `Motion Mask ${count + 1}`; polygonColor = [0, 0, 220]; } if (type == "object_mask") { - const count = allPolygons.filter( - (poly) => poly.type == "object_mask", - ).length; - polygonName = `Object Mask ${count + 1}`; polygonColor = [128, 128, 128]; // TODO - get this from config object after mutation so label can be set } + setEditingPolygons([ ...(allPolygons || []), { points: [], isFinished: false, - isUnsaved: true, + // isUnsaved: true, type, - name: polygonName, + name: "", objects: [], camera: selectedCamera, color: polygonColor, @@ -265,11 +259,24 @@ export default function MasksAndZones({ const handleSave = useCallback(() => { // console.log("handling save"); setAllPolygons([...(editingPolygons ?? [])]); - setActivePolygonIndex(undefined); - setEditPane(undefined); + + // setEditPane(undefined); setHoveredPolygonIndex(null); }, [editingPolygons]); + useEffect(() => { + console.log(isLoading); + console.log("edit pane", editPane); + if (isLoading) { + return; + } + if (!isLoading && editPane !== undefined) { + console.log("setting"); + setActivePolygonIndex(undefined); + setEditPane(undefined); + } + }, [isLoading]); + const handleCopyCoordinates = useCallback( (index: number) => { if (allPolygons && scaledWidth && scaledHeight) { @@ -287,7 +294,7 @@ export default function MasksAndZones({ [allPolygons, scaledHeight, scaledWidth], ); - useEffect(() => {}, [editPane]); + // useEffect(() => {}, [editPane]); useEffect(() => { if (cameraConfig && containerRef.current && scaledWidth && scaledHeight) { @@ -305,7 +312,7 @@ export default function MasksAndZones({ scaledHeight, ), isFinished: true, - isUnsaved: false, + // isUnsaved: false, color: zoneData.color, }), ); @@ -324,7 +331,7 @@ export default function MasksAndZones({ scaledHeight, ), isFinished: true, - isUnsaved: false, + // isUnsaved: false, color: [0, 0, 255], }), ); @@ -343,7 +350,7 @@ export default function MasksAndZones({ scaledHeight, ), isFinished: true, - isUnsaved: false, + // isUnsaved: false, color: [0, 0, 255], }), ); @@ -369,7 +376,7 @@ export default function MasksAndZones({ scaledHeight, ), isFinished: true, - isUnsaved: false, + // isUnsaved: false, color: [128, 128, 128], }, ] @@ -449,6 +456,10 @@ export default function MasksAndZones({ polygons={editingPolygons} setPolygons={setEditingPolygons} activePolygonIndex={activePolygonIndex} + scaledWidth={scaledWidth} + scaledHeight={scaledHeight} + isLoading={isLoading} + setIsLoading={setIsLoading} onCancel={handleCancel} onSave={handleSave} /> diff --git a/web/src/components/settings/MotionMaskEditPane.tsx b/web/src/components/settings/MotionMaskEditPane.tsx index f2c30a7ea..2c14562fd 100644 --- a/web/src/components/settings/MotionMaskEditPane.tsx +++ b/web/src/components/settings/MotionMaskEditPane.tsx @@ -1,27 +1,14 @@ import Heading from "../ui/heading"; import { Separator } from "../ui/separator"; import { Button } from "@/components/ui/button"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { useEffect, useMemo, useState } from "react"; -import { ATTRIBUTE_LABELS, FrigateConfig } from "@/types/frigateConfig"; -import useSWR from "swr"; -import { isMobile } from "react-device-detect"; +import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form"; +import { useMemo } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { Polygon } from "@/types/canvas"; -import { Switch } from "../ui/switch"; -import { Label } from "../ui/label"; import PolygonEditControls from "./PolygonEditControls"; +import { FaCheckCircle } from "react-icons/fa"; type MotionMaskEditPaneProps = { polygons?: Polygon[]; @@ -46,9 +33,19 @@ export default function MotionMaskEditPane({ } }, [polygons, activePolygonIndex]); + const defaultName = useMemo(() => { + if (!polygons) { + return; + } + + const count = polygons.filter((poly) => poly.type == "motion_mask").length; + + return `Motion Mask ${count + 1}`; + }, [polygons]); + const formSchema = z .object({ - polygon: z.object({ isFinished: z.boolean() }), + polygon: z.object({ name: z.string(), isFinished: z.boolean() }), }) .refine(() => polygon?.isFinished === true, { message: "The polygon drawing must be finished before saving.", @@ -59,15 +56,27 @@ export default function MotionMaskEditPane({ resolver: zodResolver(formSchema), mode: "onChange", defaultValues: { - polygon: { isFinished: polygon?.isFinished ?? false }, + polygon: { isFinished: polygon?.isFinished ?? false, name: defaultName }, }, }); function onSubmit(values: z.infer) { - console.log("form values", values); - console.log("active polygon", polygons[activePolygonIndex]); - // make sure polygon isFinished - onSave(); + // console.log("form values", values); + // if (activePolygonIndex === undefined || !polygons) { + // return; + // } + + // const updatedPolygons = [...polygons]; + // const activePolygon = updatedPolygons[activePolygonIndex]; + // updatedPolygons[activePolygonIndex] = { + // ...activePolygon, + // name: defaultName ?? "foo", + // }; + // setPolygons(updatedPolygons); + // console.log("active polygon", polygons[activePolygonIndex]); + if (onSave) { + onSave(); + } } if (!polygon) { @@ -77,15 +86,28 @@ export default function MotionMaskEditPane({ return ( <> - Motion Mask + {polygon.name.length ? "Edit" : "New"} Motion Mask +
+

+ Motion masks are used to prevent unwanted types of motion from + triggering detection. Over masking will make it more difficult for + objects to be tracked. +

+
{polygons && activePolygonIndex !== undefined && (
-
- {polygons[activePolygonIndex].points.length} points +
+ {polygons[activePolygonIndex].points.length}{" "} + {polygons[activePolygonIndex].points.length > 1 || + polygons[activePolygonIndex].points.length == 0 + ? "points" + : "point"} + {polygons[activePolygonIndex].isFinished && ( + + )}
- {polygons[activePolygonIndex].isFinished ? <> : <>}
+ ( + + + + )} + /> ("config"); + // const { data: config } = useSWR("config"); - const cameras = useMemo(() => { - if (!config) { - return []; - } + // const cameras = useMemo(() => { + // if (!config) { + // return []; + // } - return Object.values(config.cameras) - .filter((conf) => conf.ui.dashboard && conf.enabled) - .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); - }, [config]); + // return Object.values(config.cameras) + // .filter((conf) => conf.ui.dashboard && conf.enabled) + // .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); + // }, [config]); const polygon = useMemo(() => { if (polygons && activePolygonIndex !== undefined) { @@ -64,10 +64,28 @@ export default function ObjectMaskEditPane({ } }, [polygons, activePolygonIndex]); + const defaultName = useMemo(() => { + if (!polygons) { + return; + } + + const count = polygons.filter((poly) => poly.type == "object_mask").length; + + let objectType = ""; + const objects = polygon?.objects[0]; + if (objects === undefined) { + objectType = "all objects"; + } else { + objectType = objects; + } + + return `Object Mask ${count + 1} (${objectType})`; + }, [polygons, polygon]); + const formSchema = z .object({ objects: z.string(), - polygon: z.object({ isFinished: z.boolean() }), + polygon: z.object({ isFinished: z.boolean(), name: z.string() }), }) .refine(() => polygon?.isFinished === true, { message: "The polygon drawing must be finished before saving.", @@ -79,16 +97,27 @@ export default function ObjectMaskEditPane({ mode: "onChange", defaultValues: { objects: polygon?.objects[0] ?? "all_labels", - polygon: { isFinished: polygon?.isFinished ?? false }, + polygon: { isFinished: polygon?.isFinished ?? false, name: defaultName }, }, }); function onSubmit(values: z.infer) { - // polygons[activePolygonIndex].name = values.name; console.log("form values", values); - console.log("active polygon", polygons[activePolygonIndex]); - // make sure polygon isFinished - onSave(); + // if (activePolygonIndex === undefined || !polygons) { + // return; + // } + + // const updatedPolygons = [...polygons]; + // const activePolygon = updatedPolygons[activePolygonIndex]; + // updatedPolygons[activePolygonIndex] = { + // ...activePolygon, + // name: defaultName ?? "foo", + // }; + // setPolygons(updatedPolygons); + + if (onSave) { + onSave(); + } } if (!polygon) { @@ -98,15 +127,27 @@ export default function ObjectMaskEditPane({ return ( <> - Object Mask + {polygon.name.length ? "Edit" : "New"} Object Mask +
+

+ Object filter masks are used to filter out false positives for a given + object type based on location. +

+
{polygons && activePolygonIndex !== undefined && (
-
- {polygons[activePolygonIndex].points.length} points +
+ {polygons[activePolygonIndex].points.length}{" "} + {polygons[activePolygonIndex].points.length > 1 || + polygons[activePolygonIndex].points.length == 0 + ? "points" + : "point"} + {polygons[activePolygonIndex].isFinished && ( + + )}
- {polygons[activePolygonIndex].isFinished ? <> : <>} + ( + + + + )} + /> - { - // console.log(objects); - }} - /> + @@ -178,13 +223,9 @@ export default function ObjectMaskEditPane({ type ZoneObjectSelectorProps = { camera: string; - updateLabelFilter: (labels: string[] | undefined) => void; }; -export function ZoneObjectSelector({ - camera, - updateLabelFilter, -}: ZoneObjectSelectorProps) { +export function ZoneObjectSelector({ camera }: ZoneObjectSelectorProps) { const { data: config } = useSWR("config"); const cameraConfig = useMemo(() => { @@ -194,7 +235,7 @@ export function ZoneObjectSelector({ }, [config, camera]); const allLabels = useMemo(() => { - if (!config) { + if (!config || !cameraConfig) { return []; } @@ -208,34 +249,14 @@ export function ZoneObjectSelector({ }); }); - return [...labels].sort(); - }, [config]); - - const cameraLabels = useMemo(() => { - if (!cameraConfig) { - return []; - } - - const labels = new Set(); - cameraConfig.objects.track.forEach((label) => { if (!ATTRIBUTE_LABELS.includes(label)) { labels.add(label); } }); - return [...labels].sort() || []; - }, [cameraConfig]); - - const [currentLabels, setCurrentLabels] = useState( - cameraLabels.every((label, index) => label === allLabels[index]) - ? undefined - : cameraLabels, - ); - - useEffect(() => { - updateLabelFilter(currentLabels); - }, [currentLabels, updateLabelFilter]); + return [...labels].sort(); + }, [config, cameraConfig]); return ( <> diff --git a/web/src/components/settings/PolygonCanvas.tsx b/web/src/components/settings/PolygonCanvas.tsx index f6d49a681..c4c0b52fc 100644 --- a/web/src/components/settings/PolygonCanvas.tsx +++ b/web/src/components/settings/PolygonCanvas.tsx @@ -5,7 +5,7 @@ import Konva from "konva"; import type { KonvaEventObject } from "konva/lib/Node"; import { Polygon, PolygonType } from "@/types/canvas"; import { useApiHost } from "@/api"; -import { getAveragePoint } from "@/utils/canvasUtil"; +import { getAveragePoint, flattenPoints } from "@/utils/canvasUtil"; type PolygonCanvasProps = { camera: string; @@ -165,10 +165,6 @@ export function PolygonCanvas({ } }; - const flattenPoints = (points: number[][]): number[] => { - return points.reduce((acc, point) => [...acc, ...point], []); - }; - const handleGroupDragEnd = (e: KonvaEventObject) => { if (activePolygonIndex !== undefined && e.target.name() === "polygon") { const updatedPolygons = [...polygons]; diff --git a/web/src/components/settings/PolygonItem.tsx b/web/src/components/settings/PolygonItem.tsx index b41e98a52..e867ad378 100644 --- a/web/src/components/settings/PolygonItem.tsx +++ b/web/src/components/settings/PolygonItem.tsx @@ -22,7 +22,13 @@ import { HiOutlineDotsVertical, HiTrash } from "react-icons/hi"; import { isMobile } from "react-device-detect"; import { toRGBColorString } from "@/utils/canvasUtil"; import { Polygon, PolygonType } from "@/types/canvas"; -import { useState } from "react"; +import { useCallback, useMemo, useState } from "react"; +import axios from "axios"; +import { Toaster } from "@/components/ui/sonner"; +import { toast } from "sonner"; +import useSWR from "swr"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { reviewQueries } from "@/utils/zoneEdutUtil"; type PolygonItemProps = { polygon: Polygon; @@ -47,8 +53,16 @@ export default function PolygonItem({ setEditPane, handleCopyCoordinates, }: PolygonItemProps) { + const { data: config, mutate: updateConfig } = + useSWR("config"); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const cameraConfig = useMemo(() => { + if (polygon?.camera && config) { + return config.cameras[polygon.camera]; + } + }, [polygon, config]); + const polygonTypeIcons = { zone: FaDrawPolygon, motion_mask: FaObjectGroup, @@ -57,144 +71,197 @@ export default function PolygonItem({ const PolygonItemIcon = polygon ? polygonTypeIcons[polygon.type] : undefined; + const saveToConfig = useCallback( + async (polygon: Polygon) => { + if (!polygon || !cameraConfig) { + 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 = `config/set?cameras.${polygon.camera}.zones.${polygon.name}${alertQueries}${detectionQueries}`; + } + if (polygon.type == "motion_mask") { + url = `config/set?cameras.${polygon.camera}.motion.mask`; + } + axios + .put(url, { requires_restart: 0 }) + .then((res) => { + if (res.status === 200) { + toast.success(`${polygon?.name} has been deleted.`, { + position: "top-center", + }); + // setChangedValue(false); + updateConfig(); + } else { + toast.error(`Failed to save config changes: ${res.statusText}`, { + position: "top-center", + }); + } + }) + .catch((error) => { + toast.error( + `Failed to save config changes: ${error.response.data.message}`, + { position: "top-center" }, + ); + }) + .finally(() => { + // setIsLoading(false); + }); + }, + [updateConfig], + ); + const handleDelete = (index: number) => { setAllPolygons((oldPolygons) => { return oldPolygons.filter((_, i) => i !== index); }); setActivePolygonIndex(undefined); + saveToConfig(polygon); }; return ( -
setHoveredPolygonIndex(index)} - onMouseLeave={() => setHoveredPolygonIndex(null)} - style={{ - backgroundColor: - hoveredPolygonIndex === index - ? toRGBColorString(polygon.color, false) - : "", - }} - > -
- {PolygonItemIcon && ( - - )} -

{polygon.name}

-
- setDeleteDialogOpen(!deleteDialogOpen)} - > - - - Confirm Delete - - - Are you sure you want to delete the {polygon.type.replace("_", " ")}{" "} - {polygon.name}? - - - Cancel - handleDelete(index)}> - Delete - - - - + <> + - {isMobile && ( - <> - - - - - - { - setActivePolygonIndex(index); - setEditPane(polygon.type); - }} - > - Edit - - handleCopyCoordinates(index)}> - Copy - - setDeleteDialogOpen(true)}> - Delete - - - - - )} - {!isMobile && hoveredPolygonIndex === index && ( -
-
{ - setActivePolygonIndex(index); - setEditPane(polygon.type); - }} - > - - - - - Edit - -
-
handleCopyCoordinates(index)} - > - - - - - Copy coordinates - -
-
setDeleteDialogOpen(true)} - > - - - - - Delete - -
+
setHoveredPolygonIndex(index)} + onMouseLeave={() => setHoveredPolygonIndex(null)} + style={{ + backgroundColor: + hoveredPolygonIndex === index + ? toRGBColorString(polygon.color, false) + : "", + }} + > +
+ {PolygonItemIcon && ( + + )} +

{polygon.name}

- )} -
+ setDeleteDialogOpen(!deleteDialogOpen)} + > + + + Confirm Delete + + + Are you sure you want to delete the{" "} + {polygon.type.replace("_", " ")} {polygon.name}? + + + Cancel + handleDelete(index)}> + Delete + + + + + + {isMobile && ( + <> + + + + + + { + setActivePolygonIndex(index); + setEditPane(polygon.type); + }} + > + Edit + + handleCopyCoordinates(index)}> + Copy + + setDeleteDialogOpen(true)}> + Delete + + + + + )} + {!isMobile && hoveredPolygonIndex === index && ( +
+
{ + setActivePolygonIndex(index); + setEditPane(polygon.type); + }} + > + + + + + Edit + +
+
handleCopyCoordinates(index)} + > + + + + + Copy coordinates + +
+
setDeleteDialogOpen(true)} + > + + + + + Delete + +
+
+ )} +
+ ); } diff --git a/web/src/components/settings/ZoneEditPane.tsx b/web/src/components/settings/ZoneEditPane.tsx index f53916e9a..008a6eab9 100644 --- a/web/src/components/settings/ZoneEditPane.tsx +++ b/web/src/components/settings/ZoneEditPane.tsx @@ -11,7 +11,7 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { ATTRIBUTE_LABELS, FrigateConfig } from "@/types/frigateConfig"; import useSWR from "swr"; import { isMobile } from "react-device-detect"; @@ -19,14 +19,25 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { Polygon } from "@/types/canvas"; +import { reviewQueries } from "@/utils/zoneEdutUtil"; import { Switch } from "../ui/switch"; import { Label } from "../ui/label"; import PolygonEditControls from "./PolygonEditControls"; +import { FaCheckCircle } from "react-icons/fa"; +import axios from "axios"; +import { Toaster } from "@/components/ui/sonner"; +import { toast } from "sonner"; +import { flattenPoints, interpolatePoints } from "@/utils/canvasUtil"; +import ActivityIndicator from "../indicators/activity-indicator"; type ZoneEditPaneProps = { polygons?: Polygon[]; setPolygons: React.Dispatch>; activePolygonIndex?: number; + scaledWidth?: number; + scaledHeight?: number; + isLoading: boolean; + setIsLoading: React.Dispatch>; onSave?: () => void; onCancel?: () => void; }; @@ -35,10 +46,15 @@ export default function ZoneEditPane({ polygons, setPolygons, activePolygonIndex, + scaledWidth, + scaledHeight, + isLoading, + setIsLoading, onSave, onCancel, }: ZoneEditPaneProps) { - const { data: config } = useSWR("config"); + const { data: config, mutate: updateConfig } = + useSWR("config"); const cameras = useMemo(() => { if (!config) { @@ -58,47 +74,53 @@ export default function ZoneEditPane({ } }, [polygons, activePolygonIndex]); - const formSchema = z - .object({ - name: z - .string() - .min(2, { - message: "Zone name must be at least 2 characters.", - }) - .transform((val: string) => val.trim().replace(/\s+/g, "_")) - .refine( - (value: string) => { - return !cameras.map((cam) => cam.name).includes(value); - }, - { - message: "Zone name must not be the name of a camera.", - }, - ) - .refine( - (value: string) => { - const otherPolygonNames = - polygons - ?.filter((_, index) => index !== activePolygonIndex) - .map((polygon) => polygon.name) || []; + const cameraConfig = useMemo(() => { + if (polygon?.camera && config) { + return config.cameras[polygon.camera]; + } + }, [polygon, config]); - return !otherPolygonNames.includes(value); - }, - { - message: "Zone name already exists on this camera.", - }, - ), - inertia: z.coerce.number().min(1, { - message: "Inertia must be above 0.", - }), - loitering_time: z.coerce.number().min(0, { - message: "Loitering time must be greater than or equal to 0.", - }), - polygon: z.object({ isFinished: z.boolean() }), - }) - .refine(() => polygon?.isFinished === true, { + const formSchema = z.object({ + name: z + .string() + .min(2, { + message: "Zone name must be at least 2 characters.", + }) + .transform((val: string) => val.trim().replace(/\s+/g, "_")) + .refine( + (value: string) => { + return !cameras.map((cam) => cam.name).includes(value); + }, + { + message: "Zone name must not be the name of a camera.", + }, + ) + .refine( + (value: string) => { + const otherPolygonNames = + polygons + ?.filter((_, index) => index !== activePolygonIndex) + .map((polygon) => polygon.name) || []; + + return !otherPolygonNames.includes(value); + }, + { + message: "Zone name already exists on this camera.", + }, + ), + inertia: z.coerce.number().min(1, { + message: "Inertia must be above 0.", + }), + loitering_time: z.coerce.number().min(0, { + message: "Loitering time must be greater than or equal to 0.", + }), + isFinished: z.boolean().refine(() => polygon?.isFinished === true, { message: "The polygon drawing must be finished before saving.", - path: ["polygon.isFinished"], - }); + }), + objects: z.array(z.string()).optional(), + review_alerts: z.boolean().default(false).optional(), + review_detections: z.boolean().default(false).optional(), + }); const form = useForm>({ resolver: zodResolver(formSchema), @@ -106,25 +128,237 @@ export default function ZoneEditPane({ defaultValues: { name: polygon?.name ?? "", inertia: - ((polygon?.camera && + (polygon?.camera && polygon?.name && - config?.cameras[polygon.camera]?.zones[polygon.name] - ?.inertia) as number) || 3, + config?.cameras[polygon.camera]?.zones[polygon.name]?.inertia) || + 3, loitering_time: - ((polygon?.camera && + (polygon?.camera && polygon?.name && config?.cameras[polygon.camera]?.zones[polygon.name] - ?.loitering_time) as number) || 0, - polygon: { isFinished: polygon?.isFinished ?? false }, + ?.loitering_time) || + 0, + isFinished: polygon?.isFinished ?? false, + objects: polygon?.objects ?? [], + review_alerts: + (polygon?.camera && + polygon?.name && + config?.cameras[ + polygon.camera + ]?.review.alerts.required_zones.includes(polygon.name)) || + false, + review_detections: + (polygon?.camera && + polygon?.name && + config?.cameras[ + polygon.camera + ]?.review.detections.required_zones.includes(polygon.name)) || + false, }, }); + // const [changedValue, setChangedValue] = useState(false); + type FormValuesType = { + name: string; + inertia: number; + loitering_time: number; + isFinished: boolean; + objects: string[]; + review_alerts: boolean; + review_detections: boolean; + }; + + // const requiredDetectionZones = useMemo( + // () => cameraConfig?.review.detections.required_zones, + // [cameraConfig], + // ); + + // const requiredAlertZones = useMemo( + // () => cameraConfig?.review.alerts.required_zones, + // [cameraConfig], + // ); + + // const [alertQueries, setAlertQueries] = useState(""); + // const [detectionQueries, setDetectionQueries] = useState(""); + + // useEffect(() => { + // console.log("config updated!", config); + // }, [config]); + + // useEffect(() => { + // console.log("camera config updated!", cameraConfig); + // }, [cameraConfig]); + + // useEffect(() => { + // console.log("required zones updated!", requiredZones); + // }, [requiredZones]); + + const saveToConfig = useCallback( + async ( + { + name, + inertia, + loitering_time, + objects: form_objects, + review_alerts, + review_detections, + }: FormValuesType, // values submitted via the form + objects: string[], + ) => { + if (!scaledWidth || !scaledHeight || !polygon) { + return; + } + // console.log("loitering time", loitering_time); + // const alertsZones = config?.cameras[camera]?.review.alerts.required_zones; + + // const detectionsZones = + // config?.cameras[camera]?.review.detections.required_zones; + let mutatedConfig = config; + + const renamingZone = name != polygon.name && polygon.name != ""; + + if (renamingZone) { + // rename - delete old zone and replace with new + const { + alertQueries: renameAlertQueries, + detectionQueries: renameDetectionQueries, + } = reviewQueries( + polygon.name, + false, + false, + polygon.camera, + cameraConfig?.review.alerts.required_zones || [], + cameraConfig?.review.detections.required_zones || [], + ); + + try { + await axios.put( + `config/set?cameras.${polygon.camera}.zones.${polygon.name}${renameAlertQueries}${renameDetectionQueries}`, + { + requires_restart: 0, + }, + ); + + // Wait for the config to be updated + mutatedConfig = await updateConfig(); + // console.log("this should be updated...", mutatedConfig.cameras); + // console.log("check original config object...", config); + } catch (error) { + toast.error(`Failed to save config changes.`, { + position: "top-center", + }); + return; + } + } + + // console.log("out of try except", mutatedConfig); + + const coordinates = flattenPoints( + interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1), + ).join(","); + // const foo = config.cameras["doorbell"].zones["outside"].objects; + + let objectQueries = objects + .map( + (object) => + `&cameras.${polygon?.camera}.zones.${name}.objects=${object}`, + ) + .join(""); + + const same_objects = + form_objects.length == objects.length && + form_objects.every(function (element, index) { + return element === objects[index]; + }); + + // deleting objects + if (!objectQueries && !same_objects && !renamingZone) { + // console.log("deleting objects"); + objectQueries = `&cameras.${polygon?.camera}.zones.${name}.objects`; + } + + const { alertQueries, detectionQueries } = reviewQueries( + name, + review_alerts, + review_detections, + polygon.camera, + mutatedConfig?.cameras[polygon.camera]?.review.alerts.required_zones || + [], + mutatedConfig?.cameras[polygon.camera]?.review.detections + .required_zones || [], + ); + + // console.log("object queries:", objectQueries); + // console.log("alert queries:", alertQueries); + // console.log("detection queries:", detectionQueries); + + // console.log( + // `config/set?cameras.${polygon?.camera}.zones.${name}.coordinates=${coordinates}&cameras.${polygon?.camera}.zones.${name}.inertia=${inertia}&cameras.${polygon?.camera}.zones.${name}.loitering_time=${loitering_time}${objectQueries}${alertQueries}${detectionQueries}`, + // ); + + axios + .put( + `config/set?cameras.${polygon?.camera}.zones.${name}.coordinates=${coordinates}&cameras.${polygon?.camera}.zones.${name}.inertia=${inertia}&cameras.${polygon?.camera}.zones.${name}.loitering_time=${loitering_time}${objectQueries}${alertQueries}${detectionQueries}`, + { requires_restart: 0 }, + ) + .then((res) => { + if (res.status === 200) { + toast.success(`Zone ${name} saved.`, { + position: "top-center", + }); + // setChangedValue(false); + updateConfig(); + } else { + toast.error(`Failed to save config changes: ${res.statusText}`, { + position: "top-center", + }); + } + }) + .catch((error) => { + toast.error( + `Failed to save config changes: ${error.response.data.message}`, + { position: "top-center" }, + ); + }) + .finally(() => { + setIsLoading(false); + }); + }, + [ + config, + updateConfig, + polygon, + scaledWidth, + scaledHeight, + setIsLoading, + cameraConfig, + ], + ); + function onSubmit(values: z.infer) { - polygons[activePolygonIndex].name = values.name; - console.log("form values", values); - console.log("active polygon", polygons[activePolygonIndex]); - // make sure polygon isFinished - onSave(); + if (activePolygonIndex === undefined || !values || !polygons) { + return; + } + setIsLoading(true); + // polygons[activePolygonIndex].name = values.name; + // console.log("form values", values); + // console.log( + // "string", + + // flattenPoints( + // interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1), + // ).join(","), + // ); + // console.log("active polygon", polygons[activePolygonIndex]); + + saveToConfig( + values as FormValuesType, + polygons[activePolygonIndex].objects, + ); + + if (onSave) { + onSave(); + } } if (!polygon) { @@ -133,16 +367,29 @@ export default function ZoneEditPane({ return ( <> + - Zone + {polygon.name.length ? "Edit" : "New"} Zone +
+

+ Zones allow you to define a specific area of the frame so you can + determine whether or not an object is within a particular area. +

+
{polygons && activePolygonIndex !== undefined && (
-
- {polygons[activePolygonIndex].points.length} points +
+ {polygons[activePolygonIndex].points.length}{" "} + {polygons[activePolygonIndex].points.length > 1 || + polygons[activePolygonIndex].points.length == 0 + ? "points" + : "point"} + {polygons[activePolygonIndex].isFinished && ( + + )}
- {polygons[activePolygonIndex].isFinished ? <> : <>} Specifies how many frames that an object must be in a zone - before they are considered in the zone. + before they are considered in the zone. Default: 3 @@ -221,7 +468,7 @@ export default function ZoneEditPane({ Sets a minimum amount of time in seconds that the object must - be in the zone for it to activate. + be in the zone for it to activate. Default: 0 @@ -238,64 +485,69 @@ export default function ZoneEditPane({ { - // console.log(objects); + if (activePolygonIndex === undefined || !polygons) { + return; + } + const updatedPolygons = [...polygons]; + const activePolygon = updatedPolygons[activePolygonIndex]; + updatedPolygons[activePolygonIndex] = { + ...activePolygon, + objects: objects ?? [], + }; + setPolygons(updatedPolygons); }} />
- - Alerts and Detections - - When an object enters this zone, ensure it is marked as an alert - or detection. - - -
-
- - { - if (isChecked) { - return; - } - }} - /> -
-
- - { - if (isChecked) { - return; - } - }} - /> -
-
-
-
( + +
+ Alerts + + When an object enters this zone, ensure it is marked as an + alert. + +
+ + + +
+ )} + /> + ( + +
+ Detections + + When an object enters this zone, ensure it is marked as a + detection. + +
+ + + +
+ )} + /> + ( @@ -306,8 +558,20 @@ export default function ZoneEditPane({ -
@@ -319,12 +583,14 @@ export default function ZoneEditPane({ type ZoneObjectSelectorProps = { camera: string; zoneName: string; + selectedLabels: string[]; updateLabelFilter: (labels: string[] | undefined) => void; }; export function ZoneObjectSelector({ camera, zoneName, + selectedLabels, updateLabelFilter, }: ZoneObjectSelectorProps) { const { data: config } = useSWR("config"); @@ -336,7 +602,7 @@ export function ZoneObjectSelector({ }, [config, camera]); const allLabels = useMemo(() => { - if (!config) { + if (!cameraConfig || !config) { return []; } @@ -350,37 +616,27 @@ export function ZoneObjectSelector({ }); }); - return [...labels].sort(); - }, [config]); - - const zoneLabels = useMemo(() => { - if (!cameraConfig || !zoneName) { - return []; - } - - const labels = new Set(); - cameraConfig.objects.track.forEach((label) => { if (!ATTRIBUTE_LABELS.includes(label)) { labels.add(label); } }); - if (cameraConfig.zones[zoneName]) { - cameraConfig.zones[zoneName].objects.forEach((label) => { - if (!ATTRIBUTE_LABELS.includes(label)) { - labels.add(label); - } - }); + if (zoneName) { + if (cameraConfig.zones[zoneName]) { + cameraConfig.zones[zoneName].objects.forEach((label) => { + if (!ATTRIBUTE_LABELS.includes(label)) { + labels.add(label); + } + }); + } } return [...labels].sort() || []; - }, [cameraConfig, zoneName]); + }, [config, cameraConfig, zoneName]); const [currentLabels, setCurrentLabels] = useState( - zoneLabels.every((label, index) => label === allLabels[index]) - ? undefined - : zoneLabels, + selectedLabels, ); useEffect(() => { @@ -397,10 +653,10 @@ export function ZoneObjectSelector({ { if (isChecked) { - setCurrentLabels(undefined); + setCurrentLabels([]); } }} /> diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts index 721289a72..61075ff0e 100644 --- a/web/src/types/canvas.ts +++ b/web/src/types/canvas.ts @@ -7,6 +7,6 @@ export type Polygon = { objects: string[]; points: number[][]; isFinished: boolean; - isUnsaved: boolean; + // isUnsaved: boolean; color: number[]; }; diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index b20af8790..b5c89f5db 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -171,6 +171,14 @@ export interface CameraConfig { }; sync_recordings: boolean; }; + review: { + alerts: { + required_zones: string[]; + }; + detections: { + required_zones: string[]; + }; + }; rtmp: { enabled: boolean; }; diff --git a/web/src/utils/canvasUtil.ts b/web/src/utils/canvasUtil.ts index 7323f72f2..3d21f801b 100644 --- a/web/src/utils/canvasUtil.ts +++ b/web/src/utils/canvasUtil.ts @@ -64,6 +64,10 @@ export const interpolatePoints = ( return newPoints; }; +export const flattenPoints = (points: number[][]): number[] => { + return points.reduce((acc, point) => [...acc, ...point], []); +}; + export const toRGBColorString = (color: number[], darkened: boolean) => { if (color.length !== 3) { return "rgb(220,0,0,0.5)"; diff --git a/web/src/utils/zoneEdutUtil.ts b/web/src/utils/zoneEdutUtil.ts new file mode 100644 index 000000000..b108614b9 --- /dev/null +++ b/web/src/utils/zoneEdutUtil.ts @@ -0,0 +1,94 @@ +export const reviewQueries = ( + name: string, + review_alerts: boolean, + review_detections: boolean, + camera: string, + alertsZones: string[], + detectionsZones: string[], +) => { + let alertQueries = ""; + let detectionQueries = ""; + let same_alerts = false; + let same_detections = false; + // const foo = config; + + // console.log("config in func", config.cameras); + // console.log("config as foo in func", foo.cameras); + // console.log("cameraconfig in func", cameraConfig); + // console.log("required zones in func", requiredZones); + // console.log("name", name); + // console.log("alerts", alertsZones); + // console.log("detections", detectionsZones); + // console.log( + // "orig detections", + // foo?.cameras[camera]?.review.detections.required_zones, + // ); + + const alerts = new Set(alertsZones || []); + // config?.cameras[camera].review.alerts.required_zones.forEach((zone) => { + // alerts.add(zone); + // }); + if (review_alerts) { + alerts.add(name); + } else { + same_alerts = !alerts.has(name); + alerts.delete(name); + } + + alertQueries = [...alerts] + .map((zone) => `&cameras.${camera}.review.alerts.required_zones=${zone}`) + .join(""); + + const detections = new Set(detectionsZones || []); + // config?.cameras[camera].review.detections.required_zones.forEach((zone) => { + // detections.add(zone); + // }); + + if (review_detections) { + detections.add(name); + } else { + same_detections = !detections.has(name); + detections.delete(name); + } + + detectionQueries = [...detections] + .map( + (zone) => `&cameras.${camera}.review.detections.required_zones=${zone}`, + ) + .join(""); + + // console.log("dets set", detections); + + // const updatedConfig = updateConfig({ + // ...config, + // cameras: { + // ...config.cameras, + // [camera]: { + // ...config.cameras[camera], + // review: { + // ...config.cameras[camera].review, + // detection: { + // ...config.cameras[camera].review.detection, + // required_zones: [...detections], + // }, + // }, + // }, + // }, + // }); + + // console.log(updatedConfig); + + // console.log("alert queries", alertQueries); + // console.log("detection queries", detectionQueries); + + if (!alertQueries && !same_alerts) { + // console.log("deleting alerts"); + alertQueries = `&cameras.${camera}.review.alerts`; + } + if (!detectionQueries && !same_detections) { + // console.log("deleting detection"); + detectionQueries = `&cameras.${camera}.review.detections`; + } + + return { alertQueries, detectionQueries }; +};