working zone edit pane

This commit is contained in:
Josh Hawkins 2024-04-16 10:24:20 -05:00
parent 5be3cf81ea
commit 1982fa3461
11 changed files with 879 additions and 367 deletions

View File

@ -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"""

View File

@ -64,6 +64,7 @@ export default function MasksAndZones({
const { data: config } = useSWR<FrigateConfig>("config");
const [allPolygons, setAllPolygons] = useState<Polygon[]>([]);
const [editingPolygons, setEditingPolygons] = useState<Polygon[]>([]);
const [isLoading, setIsLoading] = useState(false);
// const [zoneObjects, setZoneObjects] = useState<ZoneObjects[]>([]);
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}
/>

View File

@ -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<typeof formSchema>) {
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 (
<>
<Heading as="h3" className="my-2">
Motion Mask
{polygon.name.length ? "Edit" : "New"} Motion Mask
</Heading>
<div className="text-sm text-muted-foreground my-2">
<p>
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.
</p>
</div>
<Separator className="my-3 bg-secondary" />
{polygons && activePolygonIndex !== undefined && (
<div className="flex flex-row my-2 text-sm w-full justify-between">
<div className="my-1">
{polygons[activePolygonIndex].points.length} points
<div className="my-1 inline-flex">
{polygons[activePolygonIndex].points.length}{" "}
{polygons[activePolygonIndex].points.length > 1 ||
polygons[activePolygonIndex].points.length == 0
? "points"
: "point"}
{polygons[activePolygonIndex].isFinished && (
<FaCheckCircle className="ml-2 size-5" />
)}
</div>
{polygons[activePolygonIndex].isFinished ? <></> : <></>}
<PolygonEditControls
polygons={polygons}
setPolygons={setPolygons}
@ -101,6 +123,15 @@ export default function MotionMaskEditPane({
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="polygon.name"
render={() => (
<FormItem>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="polygon.isFinished"

View File

@ -19,15 +19,15 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { useEffect, useMemo, useState } from "react";
import { useMemo } from "react";
import { ATTRIBUTE_LABELS, 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";
import { FaCheckCircle } from "react-icons/fa";
type ObjectMaskEditPaneProps = {
polygons?: Polygon[];
@ -44,17 +44,17 @@ export default function ObjectMaskEditPane({
onSave,
onCancel,
}: ObjectMaskEditPaneProps) {
const { data: config } = useSWR<FrigateConfig>("config");
// const { data: config } = useSWR<FrigateConfig>("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<typeof formSchema>) {
// 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 (
<>
<Heading as="h3" className="my-2">
Object Mask
{polygon.name.length ? "Edit" : "New"} Object Mask
</Heading>
<div className="text-sm text-muted-foreground my-2">
<p>
Object filter masks are used to filter out false positives for a given
object type based on location.
</p>
</div>
<Separator className="my-3 bg-secondary" />
{polygons && activePolygonIndex !== undefined && (
<div className="flex flex-row my-2 text-sm w-full justify-between">
<div className="my-1">
{polygons[activePolygonIndex].points.length} points
<div className="my-1 inline-flex">
{polygons[activePolygonIndex].points.length}{" "}
{polygons[activePolygonIndex].points.length > 1 ||
polygons[activePolygonIndex].points.length == 0
? "points"
: "point"}
{polygons[activePolygonIndex].isFinished && (
<FaCheckCircle className="ml-2 size-5" />
)}
</div>
{polygons[activePolygonIndex].isFinished ? <></> : <></>}
<PolygonEditControls
polygons={polygons}
setPolygons={setPolygons}
@ -122,6 +163,15 @@ export default function ObjectMaskEditPane({
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="polygon.name"
render={() => (
<FormItem>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="objects"
@ -138,12 +188,7 @@ export default function ObjectMaskEditPane({
</SelectTrigger>
</FormControl>
<SelectContent>
<ZoneObjectSelector
camera={polygon.camera}
updateLabelFilter={(objects) => {
// console.log(objects);
}}
/>
<ZoneObjectSelector camera={polygon.camera} />
</SelectContent>
</Select>
<FormDescription>
@ -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<FrigateConfig>("config");
const cameraConfig = useMemo(() => {
@ -194,7 +235,7 @@ export function ZoneObjectSelector({
}, [config, camera]);
const allLabels = useMemo<string[]>(() => {
if (!config) {
if (!config || !cameraConfig) {
return [];
}
@ -208,34 +249,14 @@ export function ZoneObjectSelector({
});
});
return [...labels].sort();
}, [config]);
const cameraLabels = useMemo<string[]>(() => {
if (!cameraConfig) {
return [];
}
const labels = new Set<string>();
cameraConfig.objects.track.forEach((label) => {
if (!ATTRIBUTE_LABELS.includes(label)) {
labels.add(label);
}
});
return [...labels].sort() || [];
}, [cameraConfig]);
const [currentLabels, setCurrentLabels] = useState<string[] | undefined>(
cameraLabels.every((label, index) => label === allLabels[index])
? undefined
: cameraLabels,
);
useEffect(() => {
updateLabelFilter(currentLabels);
}, [currentLabels, updateLabelFilter]);
return [...labels].sort();
}, [config, cameraConfig]);
return (
<>

View File

@ -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<MouseEvent | TouchEvent>) => {
if (activePolygonIndex !== undefined && e.target.name() === "polygon") {
const updatedPolygons = [...polygons];

View File

@ -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<FrigateConfig>("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 (
<div
key={index}
className="flex p-1 rounded-lg flex-row items-center justify-between mx-2 my-1.5 transition-background duration-100"
data-index={index}
onMouseEnter={() => setHoveredPolygonIndex(index)}
onMouseLeave={() => setHoveredPolygonIndex(null)}
style={{
backgroundColor:
hoveredPolygonIndex === index
? toRGBColorString(polygon.color, false)
: "",
}}
>
<div
className={`flex items-center ${
hoveredPolygonIndex === index
? "text-primary"
: "text-primary-variant"
}`}
>
{PolygonItemIcon && (
<PolygonItemIcon
className="size-5 mr-2"
style={{
fill: toRGBColorString(polygon.color, true),
color: toRGBColorString(polygon.color, true),
}}
/>
)}
<p className="cursor-default">{polygon.name}</p>
</div>
<AlertDialog
open={deleteDialogOpen}
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Delete</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
Are you sure you want to delete the {polygon.type.replace("_", " ")}{" "}
<em>{polygon.name}</em>?
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => handleDelete(index)}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<>
<Toaster position="top-center" />
{isMobile && (
<>
<DropdownMenu>
<DropdownMenuTrigger>
<HiOutlineDotsVertical className="size-5" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onClick={() => {
setActivePolygonIndex(index);
setEditPane(polygon.type);
}}
>
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCopyCoordinates(index)}>
Copy
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)}>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
{!isMobile && hoveredPolygonIndex === index && (
<div className="flex flex-row gap-2 items-center">
<div
className="cursor-pointer size-[15px]"
onClick={() => {
setActivePolygonIndex(index);
setEditPane(polygon.type);
}}
>
<Tooltip>
<TooltipTrigger>
<LuPencil
className={`size-[15px] ${
hoveredPolygonIndex === index && "text-primary-variant"
}`}
/>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
</div>
<div
className="cursor-pointer size-[15px]"
onClick={() => handleCopyCoordinates(index)}
>
<Tooltip>
<TooltipTrigger>
<LuCopy
className={`size-[15px] ${
hoveredPolygonIndex === index && "text-primary-variant"
}`}
/>
</TooltipTrigger>
<TooltipContent>Copy coordinates</TooltipContent>
</Tooltip>
</div>
<div
className="cursor-pointer size-[15px]"
onClick={() => setDeleteDialogOpen(true)}
>
<Tooltip>
<TooltipTrigger>
<HiTrash
className={`size-[15px] ${
hoveredPolygonIndex === index &&
"text-primary-variant fill-primary-variant"
}`}
/>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</div>
<div
key={index}
className="flex p-1 rounded-lg flex-row items-center justify-between mx-2 my-1.5 transition-background duration-100"
data-index={index}
onMouseEnter={() => setHoveredPolygonIndex(index)}
onMouseLeave={() => setHoveredPolygonIndex(null)}
style={{
backgroundColor:
hoveredPolygonIndex === index
? toRGBColorString(polygon.color, false)
: "",
}}
>
<div
className={`flex items-center ${
hoveredPolygonIndex === index
? "text-primary"
: "text-primary-variant"
}`}
>
{PolygonItemIcon && (
<PolygonItemIcon
className="size-5 mr-2"
style={{
fill: toRGBColorString(polygon.color, true),
color: toRGBColorString(polygon.color, true),
}}
/>
)}
<p className="cursor-default">{polygon.name}</p>
</div>
)}
</div>
<AlertDialog
open={deleteDialogOpen}
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Delete</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
Are you sure you want to delete the{" "}
{polygon.type.replace("_", " ")} <em>{polygon.name}</em>?
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => handleDelete(index)}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{isMobile && (
<>
<DropdownMenu>
<DropdownMenuTrigger>
<HiOutlineDotsVertical className="size-5" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onClick={() => {
setActivePolygonIndex(index);
setEditPane(polygon.type);
}}
>
Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCopyCoordinates(index)}>
Copy
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)}>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
{!isMobile && hoveredPolygonIndex === index && (
<div className="flex flex-row gap-2 items-center">
<div
className="cursor-pointer size-[15px]"
onClick={() => {
setActivePolygonIndex(index);
setEditPane(polygon.type);
}}
>
<Tooltip>
<TooltipTrigger>
<LuPencil
className={`size-[15px] ${
hoveredPolygonIndex === index && "text-primary-variant"
}`}
/>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
</div>
<div
className="cursor-pointer size-[15px]"
onClick={() => handleCopyCoordinates(index)}
>
<Tooltip>
<TooltipTrigger>
<LuCopy
className={`size-[15px] ${
hoveredPolygonIndex === index && "text-primary-variant"
}`}
/>
</TooltipTrigger>
<TooltipContent>Copy coordinates</TooltipContent>
</Tooltip>
</div>
<div
className="cursor-pointer size-[15px]"
onClick={() => setDeleteDialogOpen(true)}
>
<Tooltip>
<TooltipTrigger>
<HiTrash
className={`size-[15px] ${
hoveredPolygonIndex === index &&
"text-primary-variant fill-primary-variant"
}`}
/>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</div>
</div>
)}
</div>
</>
);
}

View File

@ -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<React.SetStateAction<Polygon[]>>;
activePolygonIndex?: number;
scaledWidth?: number;
scaledHeight?: number;
isLoading: boolean;
setIsLoading: React.Dispatch<React.SetStateAction<boolean>>;
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<FrigateConfig>("config");
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("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<z.infer<typeof formSchema>>({
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<typeof formSchema>) {
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 (
<>
<Toaster position="top-center" />
<Heading as="h3" className="my-2">
Zone
{polygon.name.length ? "Edit" : "New"} Zone
</Heading>
<div className="text-sm text-muted-foreground my-2">
<p>
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.
</p>
</div>
<Separator className="my-3 bg-secondary" />
{polygons && activePolygonIndex !== undefined && (
<div className="flex flex-row my-2 text-sm w-full justify-between">
<div className="my-1">
{polygons[activePolygonIndex].points.length} points
<div className="my-1 inline-flex">
{polygons[activePolygonIndex].points.length}{" "}
{polygons[activePolygonIndex].points.length > 1 ||
polygons[activePolygonIndex].points.length == 0
? "points"
: "point"}
{polygons[activePolygonIndex].isFinished && (
<FaCheckCircle className="ml-2 size-5" />
)}
</div>
{polygons[activePolygonIndex].isFinished ? <></> : <></>}
<PolygonEditControls
polygons={polygons}
setPolygons={setPolygons}
@ -197,7 +444,7 @@ export default function ZoneEditPane({
</FormControl>
<FormDescription>
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. <em>Default: 3</em>
</FormDescription>
<FormMessage />
</FormItem>
@ -221,7 +468,7 @@ export default function ZoneEditPane({
</FormControl>
<FormDescription>
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. <em>Default: 0</em>
</FormDescription>
<FormMessage />
</FormItem>
@ -238,64 +485,69 @@ export default function ZoneEditPane({
<ZoneObjectSelector
camera={polygon.camera}
zoneName={polygon.name}
selectedLabels={polygon.objects}
updateLabelFilter={(objects) => {
// console.log(objects);
if (activePolygonIndex === undefined || !polygons) {
return;
}
const updatedPolygons = [...polygons];
const activePolygon = updatedPolygons[activePolygonIndex];
updatedPolygons[activePolygonIndex] = {
...activePolygon,
objects: objects ?? [],
};
setPolygons(updatedPolygons);
}}
/>
</FormItem>
<div className="flex my-2">
<Separator className="bg-secondary" />
</div>
<FormItem>
<FormLabel>Alerts and Detections</FormLabel>
<FormDescription>
When an object enters this zone, ensure it is marked as an alert
or detection.
</FormDescription>
<FormControl>
<div className="flex flex-col gap-2.5">
<div className="flex flex-row justify-between items-center">
<Label
className="text-primary cursor-pointer"
htmlFor="mark_alert"
>
Required for Alert
</Label>
<Switch
className="ml-1"
id="mark_alert"
checked={false}
onCheckedChange={(isChecked) => {
if (isChecked) {
return;
}
}}
/>
</div>
<div className="flex flex-row justify-between items-center">
<Label
className="text-primary cursor-pointer"
htmlFor="mark_detection"
>
Required for Detection
</Label>
<Switch
className="ml-1"
id="mark_detection"
checked={false}
onCheckedChange={(isChecked) => {
if (isChecked) {
return;
}
}}
/>
</div>
</div>
</FormControl>
</FormItem>
<FormField
control={form.control}
name="polygon.isFinished"
name="review_alerts"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>Alerts</FormLabel>
<FormDescription>
When an object enters this zone, ensure it is marked as an
alert.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="review_detections"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Detections</FormLabel>
<FormDescription>
When an object enters this zone, ensure it is marked as a
detection.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="isFinished"
render={() => (
<FormItem>
<FormMessage />
@ -306,8 +558,20 @@ export default function ZoneEditPane({
<Button className="flex flex-1" onClick={onCancel}>
Cancel
</Button>
<Button variant="select" className="flex flex-1" type="submit">
Save
<Button
variant="select"
disabled={isLoading}
className="flex flex-1"
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>Saving...</span>
</div>
) : (
"Save"
)}
</Button>
</div>
</form>
@ -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<FrigateConfig>("config");
@ -336,7 +602,7 @@ export function ZoneObjectSelector({
}, [config, camera]);
const allLabels = useMemo<string[]>(() => {
if (!config) {
if (!cameraConfig || !config) {
return [];
}
@ -350,37 +616,27 @@ export function ZoneObjectSelector({
});
});
return [...labels].sort();
}, [config]);
const zoneLabels = useMemo<string[]>(() => {
if (!cameraConfig || !zoneName) {
return [];
}
const labels = new Set<string>();
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<string[] | undefined>(
zoneLabels.every((label, index) => label === allLabels[index])
? undefined
: zoneLabels,
selectedLabels,
);
useEffect(() => {
@ -397,10 +653,10 @@ export function ZoneObjectSelector({
<Switch
className="ml-1"
id="allLabels"
checked={currentLabels == undefined}
checked={!currentLabels?.length}
onCheckedChange={(isChecked) => {
if (isChecked) {
setCurrentLabels(undefined);
setCurrentLabels([]);
}
}}
/>

View File

@ -7,6 +7,6 @@ export type Polygon = {
objects: string[];
points: number[][];
isFinished: boolean;
isUnsaved: boolean;
// isUnsaved: boolean;
color: number[];
};

View File

@ -171,6 +171,14 @@ export interface CameraConfig {
};
sync_recordings: boolean;
};
review: {
alerts: {
required_zones: string[];
};
detections: {
required_zones: string[];
};
};
rtmp: {
enabled: boolean;
};

View File

@ -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)";

View File

@ -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<string>(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<string>(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 };
};