From 673541b2b9cc27a870f90b72b31a7a26a667abbf Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 14 Apr 2024 20:57:00 -0500 Subject: [PATCH] object and motion edit panes --- web/src/components/settings/MasksAndZones.tsx | 37 ++- .../settings/MotionMaskEditPane.tsx | 125 +++++++++ .../settings/ObjectMaskEditPane.tsx | 247 ++++++++++++++++++ web/src/components/settings/ZoneEditPane.tsx | 2 +- web/src/pages/Settings.tsx | 14 +- web/themes/theme-default.css | 4 +- 6 files changed, 417 insertions(+), 12 deletions(-) create mode 100644 web/src/components/settings/MotionMaskEditPane.tsx create mode 100644 web/src/components/settings/ObjectMaskEditPane.tsx diff --git a/web/src/components/settings/MasksAndZones.tsx b/web/src/components/settings/MasksAndZones.tsx index 2657812f9..3dee2287d 100644 --- a/web/src/components/settings/MasksAndZones.tsx +++ b/web/src/components/settings/MasksAndZones.tsx @@ -15,7 +15,6 @@ import { HiTrash } from "react-icons/hi"; import copy from "copy-to-clipboard"; import { toast } from "sonner"; import { Toaster } from "../ui/sonner"; -import { ZoneEditPane } from "./ZoneEditPane"; import { Button } from "../ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { @@ -29,6 +28,9 @@ import { AlertDialogTitle, } from "../ui/alert-dialog"; import Heading from "../ui/heading"; +import ZoneEditPane from "./ZoneEditPane"; +import MotionMaskEditPane from "./MotionMaskEditPane"; +import ObjectMaskEditPane from "./ObjectMaskEditPane"; const parseCoordinates = (coordinatesString: string) => { const coordinates = coordinatesString.split(","); @@ -232,7 +234,28 @@ export default function MasksAndZones({ }, [scaledHeight, aspectRatio]); const handleNewPolygon = (type: PolygonType) => { + if (!cameraConfig) { + return; + } + 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 || []), { @@ -240,10 +263,10 @@ export default function MasksAndZones({ isFinished: false, isUnsaved: true, type, - name: "", + name: polygonName, objects: [], camera: selectedCamera, - color: [0, 0, 220], + color: polygonColor, }, ]); }; @@ -330,7 +353,7 @@ export default function MasksAndZones({ ([, maskData], index) => ({ type: "object_mask" as PolygonType, camera: cameraConfig.name, - name: `All Objects Object Mask ${index + 1}`, + name: `Object Mask ${index + 1} (all objects)`, objects: [], points: interpolatePoints( parseCoordinates(maskData), @@ -356,7 +379,7 @@ export default function MasksAndZones({ { type: "object_mask" as PolygonType, camera: cameraConfig.name, - name: `${objectName.charAt(0).toUpperCase() + objectName.slice(1)} Object Mask ${globalObjectMasksCount + subIndex + 1}`, + name: `Object Mask ${globalObjectMasksCount + subIndex + 1} (${objectName})`, objects: [objectName], points: interpolatePoints( parseCoordinates(maskItem), @@ -451,7 +474,7 @@ export default function MasksAndZones({ /> )} {editPane == "motion_mask" && ( - )} {editPane == "object_mask" && ( - >; + activePolygonIndex?: number; + onSave?: () => void; + onCancel?: () => void; +}; + +export default function MotionMaskEditPane({ + polygons, + setPolygons, + activePolygonIndex, + onSave, + onCancel, +}: MotionMaskEditPaneProps) { + const polygon = useMemo(() => { + if (polygons && activePolygonIndex !== undefined) { + return polygons[activePolygonIndex]; + } else { + return null; + } + }, [polygons, activePolygonIndex]); + + const formSchema = z + .object({ + polygon: z.object({ isFinished: z.boolean() }), + }) + .refine(() => polygon?.isFinished === true, { + message: "The polygon drawing must be finished before saving.", + path: ["polygon.isFinished"], + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + mode: "onChange", + defaultValues: { + polygon: { isFinished: polygon?.isFinished ?? false }, + }, + }); + + function onSubmit(values: z.infer) { + console.log("form values", values); + console.log("active polygon", polygons[activePolygonIndex]); + // make sure polygon isFinished + onSave(); + } + + if (!polygon) { + return; + } + + return ( + <> + + Motion Mask + + + {polygons && activePolygonIndex !== undefined && ( + + + {polygons[activePolygonIndex].points.length} points + + {polygons[activePolygonIndex].isFinished ? <>> : <>>} + + + )} + + Click to draw a polygon on the image. + + + + + + + ( + + + + )} + /> + + + Cancel + + + Save + + + + + > + ); +} diff --git a/web/src/components/settings/ObjectMaskEditPane.tsx b/web/src/components/settings/ObjectMaskEditPane.tsx new file mode 100644 index 000000000..72b668a88 --- /dev/null +++ b/web/src/components/settings/ObjectMaskEditPane.tsx @@ -0,0 +1,247 @@ +import Heading from "../ui/heading"; +import { Separator } from "../ui/separator"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectSeparator, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { useEffect, useMemo, useState } from "react"; +import { ATTRIBUTES, FrigateConfig } from "@/types/frigateConfig"; +import useSWR from "swr"; +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 PolygonEditControls from "./PolygonEditControls"; + +type ObjectMaskEditPaneProps = { + polygons?: Polygon[]; + setPolygons: React.Dispatch>; + activePolygonIndex?: number; + onSave?: () => void; + onCancel?: () => void; +}; + +export default function ObjectMaskEditPane({ + polygons, + setPolygons, + activePolygonIndex, + onSave, + onCancel, +}: ObjectMaskEditPaneProps) { + const { data: config } = useSWR("config"); + + 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]); + + const polygon = useMemo(() => { + if (polygons && activePolygonIndex !== undefined) { + return polygons[activePolygonIndex]; + } else { + return null; + } + }, [polygons, activePolygonIndex]); + + const formSchema = z + .object({ + polygon: z.object({ isFinished: z.boolean() }), + }) + .refine(() => polygon?.isFinished === true, { + message: "The polygon drawing must be finished before saving.", + path: ["polygon.isFinished"], + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + mode: "onChange", + defaultValues: { + polygon: { isFinished: polygon?.isFinished ?? false }, + }, + }); + + 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 (!polygon) { + return; + } + + return ( + <> + + Object Mask + + + {polygons && activePolygonIndex !== undefined && ( + + + {polygons[activePolygonIndex].points.length} points + + {polygons[activePolygonIndex].isFinished ? <>> : <>>} + + + )} + + Click to draw a polygon on the image. + + + + + + + + Objects + + The object type that that applies to this object mask. + + { + // console.log(objects); + }} + /> + + ( + + + + )} + /> + + + Cancel + + + Save + + + + + > + ); +} + +type ZoneObjectSelectorProps = { + camera: string; + zoneName: string; + updateLabelFilter: (labels: string[] | undefined) => void; +}; + +export function ZoneObjectSelector({ + camera, + zoneName, + updateLabelFilter, +}: ZoneObjectSelectorProps) { + const { data: config } = useSWR("config"); + + const cameraConfig = useMemo(() => { + if (config && camera) { + return config.cameras[camera]; + } + }, [config, camera]); + + const allLabels = useMemo(() => { + if (!config) { + return []; + } + + const labels = new Set(); + + Object.values(config.cameras).forEach((camera) => { + camera.objects.track.forEach((label) => { + if (!ATTRIBUTES.includes(label)) { + labels.add(label); + } + }); + }); + + return [...labels].sort(); + }, [config]); + + const cameraLabels = useMemo(() => { + if (!cameraConfig) { + return []; + } + + const labels = new Set(); + + cameraConfig.objects.track.forEach((label) => { + if (!ATTRIBUTES.includes(label)) { + labels.add(label); + } + }); + + return [...labels].sort() || []; + }, [cameraConfig, zoneName]); + + const [currentLabels, setCurrentLabels] = useState( + cameraLabels.every((label, index) => label === allLabels[index]) + ? undefined + : cameraLabels, + ); + + useEffect(() => { + updateLabelFilter(currentLabels); + }, [currentLabels, updateLabelFilter]); + + return ( + <> + + + + + + + + + All object types + + {allLabels.map((item) => ( + + {item.replaceAll("_", " ").charAt(0).toUpperCase() + + item.slice(1)} + + ))} + + + + + + > + ); +} diff --git a/web/src/components/settings/ZoneEditPane.tsx b/web/src/components/settings/ZoneEditPane.tsx index 03342ea4c..68ab1f8c0 100644 --- a/web/src/components/settings/ZoneEditPane.tsx +++ b/web/src/components/settings/ZoneEditPane.tsx @@ -31,7 +31,7 @@ type ZoneEditPaneProps = { onCancel?: () => void; }; -export function ZoneEditPane({ +export default function ZoneEditPane({ polygons, setPolygons, activePolygonIndex, diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index ff4193eaa..a1fe0b30f 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -11,7 +11,7 @@ import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer"; import MotionTuner from "@/components/settings/MotionTuner"; import MasksAndZones from "@/components/settings/MasksAndZones"; import { Button } from "@/components/ui/button"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import useOptimisticState from "@/hooks/use-optimistic-state"; import Logo from "@/components/Logo"; import { isMobile } from "react-device-detect"; @@ -50,10 +50,16 @@ export default function Settings() { .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); }, [config]); - const [selectedCamera, setSelectedCamera] = useState(cameras[0].name); + const [selectedCamera, setSelectedCamera] = useState(); const [filterZoneMask, setFilterZoneMask] = useState(); + useEffect(() => { + if (cameras) { + setSelectedCamera(cameras[0].name); + } + }, [cameras]); + return ( @@ -135,6 +141,10 @@ function CameraSelectButton({ }: CameraSelectButtonProps) { const [open, setOpen] = useState(false); + if (!allCameras) { + return; + } + const trigger = (