diff --git a/web/src/components/settings/MasksAndZones.tsx b/web/src/components/settings/MasksAndZones.tsx index 373eaf5ef..035a16927 100644 --- a/web/src/components/settings/MasksAndZones.tsx +++ b/web/src/components/settings/MasksAndZones.tsx @@ -4,7 +4,7 @@ import ActivityIndicator from "@/components/indicators/activity-indicator"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { PolygonCanvas } from "./PolygonCanvas"; import { Polygon, PolygonType } from "@/types/canvas"; -import { interpolatePoints } from "@/utils/canvasUtil"; +import { interpolatePoints, parseCoordinates } from "@/utils/canvasUtil"; import { Skeleton } from "../ui/skeleton"; import { useResizeObserver } from "@/hooks/resize-observer"; import { LuExternalLink, LuInfo, LuPlus } from "react-icons/lu"; @@ -25,19 +25,6 @@ import ObjectMaskEditPane from "./ObjectMaskEditPane"; import PolygonItem from "./PolygonItem"; import { Link } from "react-router-dom"; -const parseCoordinates = (coordinatesString: string) => { - const coordinates = coordinatesString.split(","); - const points = []; - - for (let i = 0; i < coordinates.length; i += 2) { - const x = parseFloat(coordinates[i]); - const y = parseFloat(coordinates[i + 1]); - points.push([x, y]); - } - - return points; -}; - // export type ZoneObjects = { // camera: string; // zoneName: string; @@ -299,8 +286,9 @@ export default function MasksAndZones({ useEffect(() => { if (cameraConfig && containerRef.current && scaledWidth && scaledHeight) { const zones = Object.entries(cameraConfig.zones).map( - ([name, zoneData]) => ({ + ([name, zoneData], index) => ({ type: "zone" as PolygonType, + typeIndex: index, camera: cameraConfig.name, name, objects: zoneData.objects, @@ -317,28 +305,32 @@ export default function MasksAndZones({ }), ); - const motionMasks = Object.entries(cameraConfig.motion.mask).map( - ([, maskData], index) => ({ - type: "motion_mask" as PolygonType, - camera: cameraConfig.name, - name: `Motion Mask ${index + 1}`, - objects: [], - points: interpolatePoints( - parseCoordinates(maskData), - 1, - 1, - scaledWidth, - scaledHeight, - ), - isFinished: true, - // isUnsaved: false, - color: [0, 0, 255], - }), - ); + // this can be an array or a string + const motionMasks = Object.entries( + Array.isArray(cameraConfig.motion.mask) + ? cameraConfig.motion.mask + : [cameraConfig.motion.mask], + ).map(([, maskData], index) => ({ + type: "motion_mask" as PolygonType, + typeIndex: index, + camera: cameraConfig.name, + name: `Motion Mask ${index + 1}`, + objects: [], + points: interpolatePoints( + parseCoordinates(maskData), + 1, + 1, + scaledWidth, + scaledHeight, + ), + isFinished: true, + color: [0, 0, 255], + })); const globalObjectMasks = Object.entries(cameraConfig.objects.mask).map( ([, maskData], index) => ({ type: "object_mask" as PolygonType, + typeIndex: index, camera: cameraConfig.name, name: `Object Mask ${index + 1} (all objects)`, objects: [], @@ -365,6 +357,7 @@ export default function MasksAndZones({ ? [ { type: "object_mask" as PolygonType, + typeIndex: subIndex, camera: cameraConfig.name, name: `Object Mask ${globalObjectMasksCount + subIndex + 1} (${objectName})`, objects: [objectName], @@ -411,6 +404,10 @@ export default function MasksAndZones({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [cameraConfig, containerRef, scaledHeight, scaledWidth]); + useEffect(() => { + console.log("editing polygons changed:", editingPolygons); + }, [editingPolygons]); + useEffect(() => { if (editPane === undefined) { setEditingPolygons([...allPolygons]); @@ -469,6 +466,10 @@ export default function MasksAndZones({ polygons={editingPolygons} setPolygons={setEditingPolygons} activePolygonIndex={activePolygonIndex} + scaledWidth={scaledWidth} + scaledHeight={scaledHeight} + isLoading={isLoading} + setIsLoading={setIsLoading} onCancel={handleCancel} onSave={handleSave} /> @@ -478,6 +479,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 2c14562fd..a921af8ed 100644 --- a/web/src/components/settings/MotionMaskEditPane.tsx +++ b/web/src/components/settings/MotionMaskEditPane.tsx @@ -2,18 +2,32 @@ import Heading from "../ui/heading"; import { Separator } from "../ui/separator"; import { Button } from "@/components/ui/button"; import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form"; -import { useMemo } from "react"; +import { useCallback, 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 PolygonEditControls from "./PolygonEditControls"; import { FaCheckCircle } from "react-icons/fa"; +import { Polygon } from "@/types/canvas"; +import useSWR from "swr"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { + flattenPoints, + interpolatePoints, + parseCoordinates, +} from "@/utils/canvasUtil"; +import axios from "axios"; +import { toast } from "sonner"; +import { Toaster } from "../ui/sonner"; type MotionMaskEditPaneProps = { polygons?: Polygon[]; setPolygons: React.Dispatch>; activePolygonIndex?: number; + scaledWidth?: number; + scaledHeight?: number; + isLoading: boolean; + setIsLoading: React.Dispatch>; onSave?: () => void; onCancel?: () => void; }; @@ -22,9 +36,16 @@ export default function MotionMaskEditPane({ polygons, setPolygons, activePolygonIndex, + scaledWidth, + scaledHeight, + isLoading, + setIsLoading, onSave, onCancel, }: MotionMaskEditPaneProps) { + const { data: config, mutate: updateConfig } = + useSWR("config"); + const polygon = useMemo(() => { if (polygons && activePolygonIndex !== undefined) { return polygons[activePolygonIndex]; @@ -33,6 +54,12 @@ export default function MotionMaskEditPane({ } }, [polygons, activePolygonIndex]); + const cameraConfig = useMemo(() => { + if (polygon?.camera && config) { + return config.cameras[polygon.camera]; + } + }, [polygon, config]); + const defaultName = useMemo(() => { if (!polygons) { return; @@ -60,20 +87,121 @@ export default function MotionMaskEditPane({ }, }); - function onSubmit(values: z.infer) { - // console.log("form values", values); - // if (activePolygonIndex === undefined || !polygons) { - // return; - // } + const saveToConfig = useCallback(async () => { + if (!scaledWidth || !scaledHeight || !polygon || !cameraConfig) { + return; + } + // console.log("loitering time", loitering_time); + // const alertsZones = config?.cameras[camera]?.review.alerts.required_zones; - // const updatedPolygons = [...polygons]; - // const activePolygon = updatedPolygons[activePolygonIndex]; - // updatedPolygons[activePolygonIndex] = { - // ...activePolygon, - // name: defaultName ?? "foo", - // }; - // setPolygons(updatedPolygons); + // const detectionsZones = + // config?.cameras[camera]?.review.detections.required_zones; + + // console.log("out of try except", mutatedConfig); + + const coordinates = flattenPoints( + interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1), + ).join(","); + + let index = Array.isArray(cameraConfig.motion.mask) + ? cameraConfig.motion.mask.length + : 1; + + console.log("are we an array?", Array.isArray(cameraConfig.motion.mask)); + console.log("index", index); + const editingMask = polygon.name.length > 0; + + // editing existing mask, not creating a new one + if (editingMask) { + index = polygon.typeIndex; + if (polygon.name) { + const match = polygon.name.match(/\d+/); + if (match) { + // index = parseInt(match[0]) - 1; + console.log("editing, index", index); + } + } + } + + const filteredMask = Array.isArray(cameraConfig.motion.mask) + ? cameraConfig.motion.mask + : [cameraConfig.motion.mask].filter( + (_, currentIndex) => currentIndex !== index, + ); + console.log("filtered", filteredMask); + + // if (editingMask) { + // if (index != null) { + + // } + // } + filteredMask.splice(index, 0, coordinates); + console.log("filtered after splice", filteredMask); + + const queryString = filteredMask + .map((pointsArray) => { + const coordinates = flattenPoints(parseCoordinates(pointsArray)).join( + ",", + ); + return `cameras.${polygon?.camera}.motion.mask=${coordinates}&`; + }) + .join(""); + + console.log("polygon", polygon); + console.log(queryString); + + // console.log( + // `config/set?cameras.${polygon?.camera}.motion.mask=${coordinates}&${queryString}`, + // ); + console.log("motion masks", cameraConfig.motion.mask); + console.log("new coords", coordinates); + // return; + + axios + .put(`config/set?${queryString}`, { + 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); + }); + }, [updateConfig, polygon, scaledWidth, scaledHeight, setIsLoading]); + + function onSubmit(values: z.infer) { + 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(); if (onSave) { onSave(); } @@ -85,6 +213,7 @@ export default function MotionMaskEditPane({ return ( <> + {polygon.name.length ? "Edit" : "New"} Motion Mask diff --git a/web/src/components/settings/PolygonItem.tsx b/web/src/components/settings/PolygonItem.tsx index e867ad378..6bae73ef6 100644 --- a/web/src/components/settings/PolygonItem.tsx +++ b/web/src/components/settings/PolygonItem.tsx @@ -20,7 +20,11 @@ import { FaDrawPolygon, FaObjectGroup } from "react-icons/fa"; import { BsPersonBoundingBox } from "react-icons/bs"; import { HiOutlineDotsVertical, HiTrash } from "react-icons/hi"; import { isMobile } from "react-device-detect"; -import { toRGBColorString } from "@/utils/canvasUtil"; +import { + flattenPoints, + parseCoordinates, + toRGBColorString, +} from "@/utils/canvasUtil"; import { Polygon, PolygonType } from "@/types/canvas"; import { useCallback, useMemo, useState } from "react"; import axios from "axios"; @@ -33,6 +37,7 @@ import { reviewQueries } from "@/utils/zoneEdutUtil"; type PolygonItemProps = { polygon: Polygon; setAllPolygons: React.Dispatch>; + setReindexPolygons: React.Dispatch>; index: number; activePolygonIndex: number | undefined; hoveredPolygonIndex: number | null; @@ -86,13 +91,40 @@ export default function PolygonItem({ cameraConfig?.review.alerts.required_zones || [], cameraConfig?.review.detections.required_zones || [], ); - url = `config/set?cameras.${polygon.camera}.zones.${polygon.name}${alertQueries}${detectionQueries}`; + url = `cameras.${polygon.camera}.zones.${polygon.name}${alertQueries}${detectionQueries}`; } if (polygon.type == "motion_mask") { - url = `config/set?cameras.${polygon.camera}.motion.mask`; + console.log("deleting", polygon.typeIndex); + if (polygon.name) { + const match = polygon.name.match(/\d+/); + if (match) { + // index = parseInt(match[0]) - 1; + console.log("deleting, index", polygon.typeIndex); + } + } + + const filteredMask = ( + Array.isArray(cameraConfig.motion.mask) + ? cameraConfig.motion.mask + : [cameraConfig.motion.mask] + ).filter((_, currentIndex) => currentIndex !== polygon.typeIndex); + console.log(filteredMask); + + url = filteredMask + .map((pointsArray) => { + const coordinates = flattenPoints( + parseCoordinates(pointsArray), + ).join(","); + return `cameras.${polygon?.camera}.motion.mask=${coordinates}&`; + }) + .join(""); + console.log(url); + + // return; + // url = `config/set?cameras.${polygon.camera}.motion.mask`; } - axios - .put(url, { requires_restart: 0 }) + await axios + .put(`config/set?${url}`, { requires_restart: 0 }) .then((res) => { if (res.status === 200) { toast.success(`${polygon?.name} has been deleted.`, { @@ -116,12 +148,35 @@ export default function PolygonItem({ // setIsLoading(false); }); }, - [updateConfig], + [updateConfig, cameraConfig], ); - const handleDelete = (index: number) => { + const reindexPolygons = (arr: Polygon[]): Polygon[] => { + const typeCounters: { [type: string]: number } = {}; + + return arr.map((obj) => { + if (!typeCounters[obj.type]) { + typeCounters[obj.type] = 0; + } + + const newObj: Polygon = { + ...obj, + typeIndex: typeCounters[obj.type], + }; + typeCounters[obj.type]++; + return newObj; + }); + }; + + const handleDelete = (type: string, typeIndex: number) => { setAllPolygons((oldPolygons) => { - return oldPolygons.filter((_, i) => i !== index); + const filteredPolygons = oldPolygons.filter( + (polygon) => + !(polygon.type === type && polygon.typeIndex === typeIndex), + ); + console.log("filtered", filteredPolygons); + // console.log("reindexed", reindexPolygons(filteredPolygons)); + return filteredPolygons; }); setActivePolygonIndex(undefined); saveToConfig(polygon); @@ -176,7 +231,9 @@ export default function PolygonItem({ Cancel - handleDelete(index)}> + handleDelete(polygon.type, polygon.typeIndex)} + > Delete diff --git a/web/src/components/settings/ZoneEditPane.tsx b/web/src/components/settings/ZoneEditPane.tsx index 008a6eab9..74a4cb1bb 100644 --- a/web/src/components/settings/ZoneEditPane.tsx +++ b/web/src/components/settings/ZoneEditPane.tsx @@ -18,7 +18,7 @@ import { isMobile } from "react-device-detect"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import { Polygon } from "@/types/canvas"; +import { FormValuesType, Polygon } from "@/types/canvas"; import { reviewQueries } from "@/utils/zoneEdutUtil"; import { Switch } from "../ui/switch"; import { Label } from "../ui/label"; @@ -158,15 +158,6 @@ export default function ZoneEditPane({ }); // 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, diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index ebd764d99..cb19c75bf 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -56,9 +56,11 @@ export default function Settings() { useEffect(() => { if (cameras) { + // TODO: fixme setSelectedCamera(cameras[0].name); + console.log("setting selected cam"); } - }, [cameras]); + }, []); return (
diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts index 61075ff0e..dae19d673 100644 --- a/web/src/types/canvas.ts +++ b/web/src/types/canvas.ts @@ -1,6 +1,7 @@ export type PolygonType = "zone" | "motion_mask" | "object_mask"; export type Polygon = { + typeIndex: number; camera: string; name: string; type: PolygonType; @@ -10,3 +11,13 @@ export type Polygon = { // isUnsaved: boolean; color: number[]; }; + +export type FormValuesType = { + name: string; + inertia: number; + loitering_time: number; + isFinished: boolean; + objects: string[]; + review_alerts: boolean; + review_detections: boolean; +}; diff --git a/web/src/utils/canvasUtil.ts b/web/src/utils/canvasUtil.ts index 3d21f801b..141b913ad 100644 --- a/web/src/utils/canvasUtil.ts +++ b/web/src/utils/canvasUtil.ts @@ -64,6 +64,19 @@ export const interpolatePoints = ( return newPoints; }; +export const parseCoordinates = (coordinatesString: string) => { + const coordinates = coordinatesString.split(","); + const points = []; + + for (let i = 0; i < coordinates.length; i += 2) { + const x = parseFloat(coordinates[i]); + const y = parseFloat(coordinates[i + 1]); + points.push([x, y]); + } + + return points; +}; + export const flattenPoints = (points: number[][]): number[] => { return points.reduce((acc, point) => [...acc, ...point], []); };