motion and object masks

This commit is contained in:
Josh Hawkins 2024-04-12 23:38:42 -05:00
parent 30c2762e53
commit 52af3cef9b
7 changed files with 600 additions and 246 deletions

View File

@ -1,24 +1,30 @@
import { Separator } from "@/components/ui/separator";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr"; import useSWR from "swr";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { PolygonCanvas } from "./PolygonCanvas"; import { PolygonCanvas } from "./PolygonCanvas";
import { Polygon } from "@/types/canvas"; import { Polygon, PolygonType } from "@/types/canvas";
import { interpolatePoints, toRGBColorString } from "@/utils/canvasUtil"; import { interpolatePoints, toRGBColorString } from "@/utils/canvasUtil";
import { isDesktop } from "react-device-detect"; import { isDesktop, isMobile } from "react-device-detect";
import { NewZoneButton } from "./NewZoneButton";
import { Skeleton } from "../ui/skeleton"; import { Skeleton } from "../ui/skeleton";
import { useResizeObserver } from "@/hooks/resize-observer"; import { useResizeObserver } from "@/hooks/resize-observer";
import { LuCopy, LuPencil, LuTrash } from "react-icons/lu"; import { LuCopy, LuPencil, LuPlusSquare, LuTrash } from "react-icons/lu";
import { FaDrawPolygon } from "react-icons/fa"; import { FaDrawPolygon } from "react-icons/fa";
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
import { toast } from "sonner"; import { toast } from "sonner";
import { Toaster } from "../ui/sonner"; import { Toaster } from "../ui/sonner";
import Heading from "../ui/heading";
import { Input } from "../ui/input";
import { ZoneEditPane } from "./ZoneEditPane"; import { ZoneEditPane } from "./ZoneEditPane";
import { Button } from "../ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "../ui/alert-dialog";
const parseCoordinates = (coordinatesString: string) => { const parseCoordinates = (coordinatesString: string) => {
const coordinates = coordinatesString.split(","); const coordinates = coordinatesString.split(",");
@ -33,6 +39,139 @@ const parseCoordinates = (coordinatesString: string) => {
return points; return points;
}; };
type PolygonItemProps = {
polygon: Polygon;
setAllPolygons: React.Dispatch<React.SetStateAction<Polygon[]>>;
index: number;
activePolygonIndex: number | undefined;
hoveredPolygonIndex: number | null;
setHoveredPolygonIndex: (index: number | null) => void;
deleteDialogOpen: boolean;
setDeleteDialogOpen: (open: boolean) => void;
setActivePolygonIndex: (index: number | undefined) => void;
setEditPane: (type: PolygonType) => void;
handleCopyCoordinates: (index: number) => void;
};
function PolygonItem({
polygon,
setAllPolygons,
index,
activePolygonIndex,
hoveredPolygonIndex,
setHoveredPolygonIndex,
deleteDialogOpen,
setDeleteDialogOpen,
setActivePolygonIndex,
setEditPane,
handleCopyCoordinates,
}: PolygonItemProps) {
return (
<div
key={index}
className="flex p-1 rounded-lg flex-row items-center justify-between mx-2 mb-1 transition-background duration-100"
onMouseEnter={() => setHoveredPolygonIndex(index)}
onMouseLeave={() => setHoveredPolygonIndex(null)}
style={{
backgroundColor:
hoveredPolygonIndex === index
? toRGBColorString(polygon.color, false)
: "",
}}
>
{isMobile && <></>}
<div
className={`flex items-center ${
activePolygonIndex === index
? "text-primary"
: "text-muted-foreground"
}`}
>
<FaDrawPolygon
className="size-4 mr-2"
style={{
fill: toRGBColorString(polygon.color, true),
color: toRGBColorString(polygon.color, true),
}}
/>
<p className="cursor-default">{polygon.name}</p>
</div>
{deleteDialogOpen && (
<AlertDialog
open={deleteDialogOpen}
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Delete</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
Are you sure you want to delete this{" "}
{polygon.type.replace("_", " ")}?
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setAllPolygons((oldPolygons) => {
return oldPolygons.filter((_, i) => i !== index);
});
setActivePolygonIndex(undefined);
}}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{hoveredPolygonIndex === index && (
<div className="flex flex-row gap-2">
<div
className="cursor-pointer"
onClick={() => {
setActivePolygonIndex(index);
setEditPane(polygon.type);
}}
>
<LuPencil
className={`size-4 ${
activePolygonIndex === index
? "text-primary"
: "text-muted-foreground"
}`}
/>
</div>
<div
className="cursor-pointer"
onClick={() => handleCopyCoordinates(index)}
>
<LuCopy
className={`size-4 ${
activePolygonIndex === index
? "text-primary"
: "text-muted-foreground"
}`}
/>
</div>
<div
className="cursor-pointer"
onClick={() => setDeleteDialogOpen(true)}
>
<LuTrash
className={`size-4 ${
activePolygonIndex === index
? "text-primary fill-primary"
: "text-muted-foreground fill-muted-foreground"
}`}
/>
</div>
</div>
)}
</div>
);
}
export type ZoneObjects = { export type ZoneObjects = {
camera: string; camera: string;
zoneName: string; zoneName: string;
@ -41,24 +180,30 @@ export type ZoneObjects = {
type MasksAndZoneProps = { type MasksAndZoneProps = {
selectedCamera: string; selectedCamera: string;
setSelectedCamera: React.Dispatch<React.SetStateAction<string>>;
}; };
export default function MasksAndZones({ export default function MasksAndZones({ selectedCamera }: MasksAndZoneProps) {
selectedCamera,
setSelectedCamera,
}: MasksAndZoneProps) {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const [zonePolygons, setZonePolygons] = useState<Polygon[]>([]); const [allPolygons, setAllPolygons] = useState<Polygon[]>([]);
const [editingPolygons, setEditingPolygons] = useState<Polygon[]>();
const [zoneObjects, setZoneObjects] = useState<ZoneObjects[]>([]); const [zoneObjects, setZoneObjects] = useState<ZoneObjects[]>([]);
const [activePolygonIndex, setActivePolygonIndex] = useState< const [activePolygonIndex, setActivePolygonIndex] = useState<
number | undefined number | undefined
>(undefined); >(undefined);
const [hoveredPolygonIndex, setHoveredPolygonIndex] = useState<number | null>(
null,
);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const editViews = ["zone", "motion_mask", "object_mask", undefined] as const; // const polygonTypes = [
// "zone",
// "motion_mask",
// "object_mask",
// undefined,
// ] as const;
type EditPaneType = (typeof editViews)[number]; // type EditPaneType = (typeof polygonTypes)[number];
const [editPane, setEditPane] = useState<EditPaneType>(undefined); const [editPane, setEditPane] = useState<PolygonType | undefined>(undefined);
const cameras = useMemo(() => { const cameras = useMemo(() => {
if (!config) { if (!config) {
@ -121,95 +266,77 @@ export default function MasksAndZones({
[setZoneObjects], [setZoneObjects],
); );
const growe = useMemo(() => { // const getCameraAspect = useCallback(
if (!cameraConfig) { // (cam: string) => {
return; // if (!config) {
} // return undefined;
// }
const aspectRatio = cameraConfig.detect.width / cameraConfig.detect.height; // const camera = config.cameras[cam];
if (aspectRatio > 2) { // if (!camera) {
return "aspect-wide"; // return undefined;
} else if (aspectRatio < 16 / 9) { // }
if (isDesktop) {
return "size-full aspect-tall";
} else {
return "size-full";
}
} else {
return "size-full aspect-video";
}
}, [cameraConfig]);
const getCameraAspect = useCallback( // return camera.detect.width / camera.detect.height;
(cam: string) => { // },
// [config],
// );
const [{ width: containerWidth, height: containerHeight }] =
useResizeObserver(containerRef);
// const { width: detectWidth, height: detectHeight } = cameraConfig
// ? cameraConfig.detect
// : { width: 1, height: 1 };
const aspectRatio = useMemo(() => {
if (!config) { if (!config) {
return undefined; return undefined;
} }
const camera = config.cameras[cam]; const camera = config.cameras[selectedCamera];
if (!camera) { if (!camera) {
return undefined; return undefined;
} }
return camera.detect.width / camera.detect.height; return camera.detect.width / camera.detect.height;
}, }, [config, selectedCamera]);
[config],
);
const mainCameraAspect = useMemo(() => { const detectHeight = useMemo(() => {
const aspectRatio = getCameraAspect(selectedCamera); if (!config) {
return undefined;
if (!aspectRatio) {
return "normal";
} else if (aspectRatio > 2) {
return "wide";
} else if (aspectRatio < 16 / 9) {
return "tall";
} else {
return "normal";
} }
}, [getCameraAspect, selectedCamera]);
const grow = useMemo(() => { const camera = config.cameras[selectedCamera];
if (mainCameraAspect == "wide") {
return "w-full aspect-wide"; if (!camera) {
} else if (mainCameraAspect == "tall") { return undefined;
if (isDesktop) {
return "size-full aspect-tall flex flex-col justify-center";
} else {
return "size-full";
} }
} else {
return "w-full aspect-video";
}
}, [mainCameraAspect]);
const [{ width: containerWidth, height: containerHeight }] = return camera.detect.height;
useResizeObserver(containerRef); }, [config, selectedCamera]);
const { width, height } = cameraConfig
? cameraConfig.detect
: { width: 1, height: 1 };
const aspectRatio = width / height;
const stretch = true; const stretch = true;
const fitAspect = 16 / 9; const fitAspect = 1;
// console.log(containerRef.current?.clientHeight);
const scaledHeight = useMemo(() => { const scaledHeight = useMemo(() => {
if (containerRef.current && aspectRatio && detectHeight) {
console.log("recalc", Date.now());
const scaledHeight = const scaledHeight =
aspectRatio < (fitAspect ?? 0) aspectRatio < (fitAspect ?? 0)
? Math.floor( ? Math.floor(
Math.min(containerHeight, containerRef.current?.clientHeight), Math.min(containerHeight, containerRef.current?.clientHeight),
) )
: Math.floor(containerWidth / aspectRatio); : Math.floor(containerWidth / aspectRatio);
const finalHeight = stretch ? scaledHeight : Math.min(scaledHeight, height); const finalHeight = stretch
? scaledHeight
: Math.min(scaledHeight, detectHeight);
if (finalHeight > 0) { if (finalHeight > 0) {
return finalHeight; return finalHeight;
} }
}
return 100; return 100;
}, [ }, [
@ -217,19 +344,52 @@ export default function MasksAndZones({
containerWidth, containerWidth,
containerHeight, containerHeight,
fitAspect, fitAspect,
height, detectHeight,
stretch, stretch,
]); ]);
const scaledWidth = useMemo( const scaledWidth = useMemo(() => {
() => Math.ceil(scaledHeight * aspectRatio), if (aspectRatio && scaledHeight) {
[scaledHeight, aspectRatio], return Math.ceil(scaledHeight * aspectRatio);
); }
return 100;
}, [scaledHeight, aspectRatio]);
const handleNewPolygon = (type: PolygonType) => {
setAllPolygons([
...(allPolygons || []),
{
points: [],
isFinished: false,
// isUnsaved: true,
type,
name: "",
camera: selectedCamera,
color: [0, 0, 220],
},
]);
setActivePolygonIndex(allPolygons.length);
};
const handleCancel = useCallback(() => {
setEditPane(undefined);
// setAllPolygons(allPolygons.filter((poly) => !poly.isUnsaved));
setActivePolygonIndex(undefined);
setHoveredPolygonIndex(null);
}, []);
const handleSave = useCallback(() => {
setAllPolygons([...(editingPolygons ?? [])]);
setActivePolygonIndex(undefined);
setEditPane(undefined);
setHoveredPolygonIndex(null);
}, [editingPolygons]);
const handleCopyCoordinates = useCallback( const handleCopyCoordinates = useCallback(
(index: number) => { (index: number) => {
if (zonePolygons) { if (allPolygons && scaledWidth) {
const poly = zonePolygons[index]; const poly = allPolygons[index];
copy( copy(
interpolatePoints(poly.points, scaledWidth, scaledHeight, 1, 1) interpolatePoints(poly.points, scaledWidth, scaledHeight, 1, 1)
.map((point) => `${point[0]},${point[1]}`) .map((point) => `${point[0]},${point[1]}`)
@ -240,13 +400,14 @@ export default function MasksAndZones({
toast.error("Could not copy coordinates to clipboard."); toast.error("Could not copy coordinates to clipboard.");
} }
}, },
[zonePolygons, scaledHeight, scaledWidth], [allPolygons, scaledHeight, scaledWidth],
); );
useEffect(() => { useEffect(() => {
if (cameraConfig && containerRef.current) { if (cameraConfig && containerRef.current && scaledWidth) {
setZonePolygons( setAllPolygons([
Object.entries(cameraConfig.zones).map(([name, zoneData]) => ({ ...Object.entries(cameraConfig.zones).map(([name, zoneData]) => ({
type: "zone" as PolygonType, // Add the type property here
camera: cameraConfig.name, camera: cameraConfig.name,
name, name,
points: interpolatePoints( points: interpolatePoints(
@ -259,7 +420,46 @@ export default function MasksAndZones({
isFinished: true, isFinished: true,
color: zoneData.color, color: zoneData.color,
})), })),
); ...Object.entries(cameraConfig.motion.mask).map(([, maskData]) => ({
type: "motion_mask" as PolygonType,
camera: cameraConfig.name,
name: "motion_mask",
points: interpolatePoints(
parseCoordinates(maskData),
1,
1,
scaledWidth,
scaledHeight,
),
isFinished: true,
color: [0, 0, 255],
})),
...Object.entries(cameraConfig.objects.filters).flatMap(
([objectName, { mask }]): Polygon[] =>
mask !== null && mask !== undefined
? mask.flatMap((maskItem) =>
maskItem !== null && maskItem !== undefined
? [
{
type: "object_mask" as PolygonType,
camera: cameraConfig.name,
name: objectName,
points: interpolatePoints(
parseCoordinates(maskItem),
1,
1,
scaledWidth,
scaledHeight,
),
isFinished: true,
color: [128, 128, 128],
},
]
: [],
)
: [],
),
]);
setZoneObjects( setZoneObjects(
Object.entries(cameraConfig.zones).map(([name, zoneData]) => ({ Object.entries(cameraConfig.zones).map(([name, zoneData]) => ({
@ -273,6 +473,13 @@ export default function MasksAndZones({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [cameraConfig, containerRef]); }, [cameraConfig, containerRef]);
useEffect(() => {
if (editPane === undefined) {
setEditingPolygons([...allPolygons]);
console.log(allPolygons);
}
}, [setEditingPolygons, allPolygons, editPane]);
// useEffect(() => { // useEffect(() => {
// console.log( // console.log(
// "config zone objects", // "config zone objects",
@ -297,119 +504,132 @@ export default function MasksAndZones({
return ( return (
<> <>
{cameraConfig && zonePolygons && ( {cameraConfig && allPolygons && (
<div className="flex flex-col md:flex-row size-full"> <div className="flex flex-col md:flex-row size-full">
<Toaster position="top-center" /> <Toaster position="top-center" />
<div className="flex flex-col order-last w-full overflow-y-auto md:w-3/12 md:order-none md:mr-2 rounded-lg border-secondary-foreground border-[1px] p-2 bg-background_alt"> <div className="flex flex-col order-last w-full overflow-y-auto md:w-3/12 md:order-none md:mr-2 rounded-lg border-secondary-foreground border-[1px] p-2 bg-background_alt">
{/* <div className="flex mb-3">
<Separator />
</div> */}
{editPane == "zone" && ( {editPane == "zone" && (
<ZoneEditPane <ZoneEditPane
polygons={zonePolygons} polygons={allPolygons}
activePolygonIndex={activePolygonIndex} activePolygonIndex={activePolygonIndex}
onCancel={() => { onCancel={handleCancel}
setEditPane(undefined); onSave={handleSave}
setActivePolygonIndex(undefined);
}}
/> />
)} )}
{editPane == "motion_mask" && ( {editPane == "motion_mask" && (
<ZoneEditPane <ZoneEditPane
polygons={zonePolygons} polygons={allPolygons}
activePolygonIndex={activePolygonIndex} activePolygonIndex={activePolygonIndex}
onCancel={() => { onCancel={handleCancel}
setEditPane(undefined);
setActivePolygonIndex(undefined);
}}
/> />
)} )}
{editPane == "object_mask" && ( {editPane == "object_mask" && (
<ZoneEditPane <ZoneEditPane
polygons={zonePolygons} polygons={allPolygons}
activePolygonIndex={activePolygonIndex} activePolygonIndex={activePolygonIndex}
onCancel={() => { onCancel={handleCancel}
setEditPane(undefined);
setActivePolygonIndex(undefined);
}}
/> />
)} )}
{editPane == undefined && ( {editPane == undefined && (
<> <>
<div className="flex flex-row justify-between items-center mb-3"> <div className="flex flex-row justify-between items-center mb-3">
<div className="text-md">Zones</div> <div className="text-md">Zones</div>
<NewZoneButton <Button
camera={cameraConfig.name} variant="ghost"
polygons={zonePolygons} className="h-8 px-0"
setPolygons={setZonePolygons}
activePolygonIndex={activePolygonIndex}
setActivePolygonIndex={setActivePolygonIndex}
/>
</div>
{zonePolygons.map((polygon, index) => (
<div
key={index}
className="flex p-1 rounded-lg flex-row items-center justify-between mx-2 mb-1"
// style={{
// backgroundColor:
// activePolygonIndex === index
// ? toRGBColorString(polygon.color, false)
// : "",
// }}
>
<div
className={`flex items-center ${activePolygonIndex === index ? "text-primary" : "text-muted-foreground"}`}
>
<FaDrawPolygon
className="size-4 mr-2"
style={{
fill: toRGBColorString(polygon.color, true),
color: toRGBColorString(polygon.color, true),
}}
/>
<p>{polygon.name}</p>
</div>
<div className="flex flex-row gap-2">
<div
className="cursor-pointer"
onClick={() => { onClick={() => {
setActivePolygonIndex(index);
setEditPane("zone"); setEditPane("zone");
// if (activePolygonIndex == index) { handleNewPolygon("zone");
// setActivePolygonIndex(null);
// } else {
// setActivePolygonIndex(index);
// }
}} }}
> >
<LuPencil <LuPlusSquare />
className={`size-4 ${activePolygonIndex === index ? "text-primary" : "text-muted-foreground"}`} </Button>
/>
</div> </div>
<div {allPolygons
className="cursor-pointer" .flatMap((polygon, index) =>
onClick={() => handleCopyCoordinates(index)} polygon.type === "zone" ? [{ polygon, index }] : [],
> )
<LuCopy .map(({ polygon, index }) => (
className={`size-4 ${activePolygonIndex === index ? "text-primary" : "text-muted-foreground"}`} <PolygonItem
key={index}
polygon={polygon}
index={index}
activePolygonIndex={activePolygonIndex}
hoveredPolygonIndex={hoveredPolygonIndex}
setHoveredPolygonIndex={setHoveredPolygonIndex}
deleteDialogOpen={deleteDialogOpen}
setDeleteDialogOpen={setDeleteDialogOpen}
setActivePolygonIndex={setActivePolygonIndex}
setEditPane={setEditPane}
setAllPolygons={setAllPolygons}
handleCopyCoordinates={handleCopyCoordinates}
/> />
</div> ))}
<div <div className="flex flex-row justify-between items-center my-3">
className="cursor-pointer" <div className="text-md">Motion Masks</div>
<Button
variant="ghost"
className="h-8 px-0"
onClick={() => { onClick={() => {
setZonePolygons((oldPolygons) => { setEditPane("motion_mask");
return oldPolygons.filter((_, i) => i !== index); handleNewPolygon("motion_mask");
});
setActivePolygonIndex(undefined);
}} }}
> >
<LuTrash <LuPlusSquare />
className={`size-4 ${activePolygonIndex === index ? "text-primary fill-primary" : "text-muted-foreground fill-muted-foreground"}`} </Button>
</div>
{allPolygons
.flatMap((polygon, index) =>
polygon.type === "motion_mask" ? [{ polygon, index }] : [],
)
.map(({ polygon, index }) => (
<PolygonItem
key={index}
polygon={polygon}
index={index}
activePolygonIndex={activePolygonIndex}
hoveredPolygonIndex={hoveredPolygonIndex}
setHoveredPolygonIndex={setHoveredPolygonIndex}
deleteDialogOpen={deleteDialogOpen}
setDeleteDialogOpen={setDeleteDialogOpen}
setActivePolygonIndex={setActivePolygonIndex}
setEditPane={setEditPane}
setAllPolygons={setAllPolygons}
handleCopyCoordinates={handleCopyCoordinates}
/> />
))}
<div className="flex flex-row justify-between items-center my-3">
<div className="text-md">Object Masks</div>
<Button
variant="ghost"
className="h-8 px-0"
onClick={() => {
setEditPane("motion_mask");
handleNewPolygon("motion_mask");
}}
>
<LuPlusSquare />
</Button>
</div> </div>
</div> {allPolygons
</div> .flatMap((polygon, index) =>
polygon.type === "object_mask" ? [{ polygon, index }] : [],
)
.map(({ polygon, index }) => (
<PolygonItem
key={index}
polygon={polygon}
index={index}
activePolygonIndex={activePolygonIndex}
hoveredPolygonIndex={hoveredPolygonIndex}
setHoveredPolygonIndex={setHoveredPolygonIndex}
deleteDialogOpen={deleteDialogOpen}
setDeleteDialogOpen={setDeleteDialogOpen}
setActivePolygonIndex={setActivePolygonIndex}
setEditPane={setEditPane}
setAllPolygons={setAllPolygons}
handleCopyCoordinates={handleCopyCoordinates}
/>
))} ))}
</> </>
)} )}
@ -422,7 +642,7 @@ export default function MasksAndZones({
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{zonePolygons.map((polygon, index) => ( {allPolygons.map((polygon, index) => (
<TableRow key={index}> <TableRow key={index}>
<TableCell className="font-medium"> <TableCell className="font-medium">
{polygon.name} {polygon.name}
@ -469,16 +689,16 @@ export default function MasksAndZones({
</div> </div>
<ZoneControls <ZoneControls
camera={cameraConfig.name} camera={cameraConfig.name}
polygons={zonePolygons} polygons={allPolygons}
setPolygons={setZonePolygons} setPolygons={setAllPolygons}
activePolygonIndex={activePolygonIndex} activePolygonIndex={activePolygonIndex}
setActivePolygonIndex={setActivePolygonIndex} setActivePolygonIndex={setActivePolygonIndex}
/> />
<div className="flex flex-col justify-center items-center m-auto w-[30%] bg-secondary"> <div className="flex flex-col justify-center items-center m-auto w-[30%] bg-secondary">
<pre style={{ whiteSpace: "pre-wrap" }}> <pre style={{ whiteSpace: "pre-wrap" }}>
{JSON.stringify( {JSON.stringify(
zonePolygons && allPolygons &&
zonePolygons.map((polygon) => allPolygons.map((polygon) =>
interpolatePoints( interpolatePoints(
polygon.points, polygon.points,
scaledWidth, scaledWidth,
@ -503,9 +723,11 @@ export default function MasksAndZones({
camera={cameraConfig.name} camera={cameraConfig.name}
width={scaledWidth} width={scaledWidth}
height={scaledHeight} height={scaledHeight}
polygons={zonePolygons} scale={1}
setPolygons={setZonePolygons} polygons={editingPolygons}
setPolygons={setEditingPolygons}
activePolygonIndex={activePolygonIndex} activePolygonIndex={activePolygonIndex}
hoveredPolygonIndex={hoveredPolygonIndex}
/> />
) : ( ) : (
<Skeleton className="w-full h-full" /> <Skeleton className="w-full h-full" />

View File

@ -73,6 +73,7 @@ export function NewZoneButton({
{ {
points: [], points: [],
isFinished: false, isFinished: false,
// isUnsaved: true,
name: zoneName, name: zoneName,
camera: camera, camera: camera,
color: [220, 0, 0], color: [220, 0, 0],

View File

@ -1,27 +1,32 @@
import React, { useMemo, useRef, useState, useEffect } from "react"; import React, { useMemo, useRef, useState, useEffect } from "react";
import PolygonDrawer from "./PolygonDrawer"; import PolygonDrawer from "./PolygonDrawer";
import { Stage, Layer, Image } from "react-konva"; import { Stage, Layer, Image, Text } from "react-konva";
import Konva from "konva"; import Konva from "konva";
import type { KonvaEventObject } from "konva/lib/Node"; import type { KonvaEventObject } from "konva/lib/Node";
import { Polygon } from "@/types/canvas"; import { Polygon } from "@/types/canvas";
import { useApiHost } from "@/api"; import { useApiHost } from "@/api";
import { getAveragePoint } from "@/utils/canvasUtil";
type PolygonCanvasProps = { type PolygonCanvasProps = {
camera: string; camera: string;
width: number; width: number;
height: number; height: number;
scale: number;
polygons: Polygon[]; polygons: Polygon[];
setPolygons: React.Dispatch<React.SetStateAction<Polygon[]>>; setPolygons: React.Dispatch<React.SetStateAction<Polygon[]>>;
activePolygonIndex: number | undefined; activePolygonIndex: number | undefined;
hoveredPolygonIndex: number | null;
}; };
export function PolygonCanvas({ export function PolygonCanvas({
camera, camera,
width, width,
height, height,
scale,
polygons, polygons,
setPolygons, setPolygons,
activePolygonIndex, activePolygonIndex,
hoveredPolygonIndex,
}: PolygonCanvasProps) { }: PolygonCanvasProps) {
const [image, setImage] = useState<HTMLImageElement | undefined>(); const [image, setImage] = useState<HTMLImageElement | undefined>();
const imageRef = useRef<Konva.Image | null>(null); const imageRef = useRef<Konva.Image | null>(null);
@ -68,7 +73,7 @@ export function PolygonCanvas({
}; };
const handleMouseDown = (e: KonvaEventObject<MouseEvent | TouchEvent>) => { const handleMouseDown = (e: KonvaEventObject<MouseEvent | TouchEvent>) => {
if (!activePolygonIndex || !polygons) { if (activePolygonIndex === undefined || !polygons) {
return; return;
} }
@ -103,7 +108,7 @@ export function PolygonCanvas({
const handleMouseOverStartPoint = ( const handleMouseOverStartPoint = (
e: KonvaEventObject<MouseEvent | TouchEvent>, e: KonvaEventObject<MouseEvent | TouchEvent>,
) => { ) => {
if (!activePolygonIndex || !polygons) { if (activePolygonIndex === undefined || !polygons) {
return; return;
} }
@ -118,7 +123,7 @@ export function PolygonCanvas({
) => { ) => {
e.currentTarget.scale({ x: 1, y: 1 }); e.currentTarget.scale({ x: 1, y: 1 });
if (!activePolygonIndex || !polygons) { if (activePolygonIndex === undefined || !polygons) {
return; return;
} }
@ -134,7 +139,7 @@ export function PolygonCanvas({
const handlePointDragMove = ( const handlePointDragMove = (
e: KonvaEventObject<MouseEvent | TouchEvent>, e: KonvaEventObject<MouseEvent | TouchEvent>,
) => { ) => {
if (!activePolygonIndex || !polygons) { if (activePolygonIndex === undefined || !polygons) {
return; return;
} }
@ -165,7 +170,7 @@ export function PolygonCanvas({
}; };
const handleGroupDragEnd = (e: KonvaEventObject<MouseEvent | TouchEvent>) => { const handleGroupDragEnd = (e: KonvaEventObject<MouseEvent | TouchEvent>) => {
if (activePolygonIndex && e.target.name() === "polygon") { if (activePolygonIndex !== undefined && e.target.name() === "polygon") {
const updatedPolygons = [...polygons]; const updatedPolygons = [...polygons];
const activePolygon = updatedPolygons[activePolygonIndex]; const activePolygon = updatedPolygons[activePolygonIndex];
const result: number[][] = []; const result: number[][] = [];
@ -186,6 +191,8 @@ export function PolygonCanvas({
ref={stageRef} ref={stageRef}
width={width} width={width}
height={height} height={height}
scaleX={scale}
scaleY={scale}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onTouchStart={handleMouseDown} onTouchStart={handleMouseDown}
> >
@ -199,11 +206,13 @@ export function PolygonCanvas({
height={height} height={height}
/> />
{polygons?.map((polygon, index) => ( {polygons?.map((polygon, index) => (
<React.Fragment key={index}>
<PolygonDrawer <PolygonDrawer
key={index} key={index}
points={polygon.points} points={polygon.points}
flattenedPoints={flattenPoints(polygon.points)} flattenedPoints={flattenPoints(polygon.points)}
isActive={index === activePolygonIndex} isActive={index === activePolygonIndex}
isHovered={index === hoveredPolygonIndex}
isFinished={polygon.isFinished} isFinished={polygon.isFinished}
color={polygon.color} color={polygon.color}
handlePointDragMove={handlePointDragMove} handlePointDragMove={handlePointDragMove}
@ -211,6 +220,20 @@ export function PolygonCanvas({
handleMouseOverStartPoint={handleMouseOverStartPoint} handleMouseOverStartPoint={handleMouseOverStartPoint}
handleMouseOutStartPoint={handleMouseOutStartPoint} handleMouseOutStartPoint={handleMouseOutStartPoint}
/> />
{index === hoveredPolygonIndex && (
<Text
text={polygon.name}
align="left"
verticalAlign="top"
x={
getAveragePoint(flattenPoints(polygon.points)).x
// - (polygon.name.length * 16 * 0.6) / 2
}
y={getAveragePoint(flattenPoints(polygon.points)).y} //- 16 / 2}
fontSize={16}
/>
)}
</React.Fragment>
))} ))}
</Layer> </Layer>
</Stage> </Stage>

View File

@ -9,6 +9,7 @@ type PolygonDrawerProps = {
points: number[][]; points: number[][];
flattenedPoints: number[]; flattenedPoints: number[];
isActive: boolean; isActive: boolean;
isHovered: boolean;
isFinished: boolean; isFinished: boolean;
color: number[]; color: number[];
handlePointDragMove: (e: KonvaEventObject<MouseEvent | TouchEvent>) => void; handlePointDragMove: (e: KonvaEventObject<MouseEvent | TouchEvent>) => void;
@ -25,6 +26,7 @@ export default function PolygonDrawer({
points, points,
flattenedPoints, flattenedPoints,
isActive, isActive,
isHovered,
isFinished, isFinished,
color, color,
handlePointDragMove, handlePointDragMove,
@ -99,7 +101,7 @@ export default function PolygonDrawer({
stroke={colorString(true)} stroke={colorString(true)}
strokeWidth={3} strokeWidth={3}
closed={isFinished} closed={isFinished}
fill={colorString(isActive ? true : false)} fill={colorString(isActive || isHovered ? true : false)}
/> />
{points.map((point, index) => { {points.map((point, index) => {
if (!isActive) { if (!isActive) {

View File

@ -16,7 +16,7 @@ import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { GeneralFilterContent } from "../filter/ReviewFilterGroup"; import { GeneralFilterContent } from "../filter/ReviewFilterGroup";
import { FaObjectGroup } from "react-icons/fa"; import { FaObjectGroup } from "react-icons/fa";
import { ATTRIBUTES, FrigateConfig } from "@/types/frigateConfig"; import { ATTRIBUTES, CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr"; import useSWR from "swr";
import { isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
@ -88,7 +88,9 @@ export function ZoneObjectSelector({
}, [cameraConfig, zoneName]); }, [cameraConfig, zoneName]);
const [currentLabels, setCurrentLabels] = useState<string[] | undefined>( const [currentLabels, setCurrentLabels] = useState<string[] | undefined>(
zoneLabels, zoneLabels.every((label, index) => label === allLabels[index])
? undefined
: zoneLabels,
); );
useEffect(() => { useEffect(() => {
@ -103,7 +105,7 @@ export function ZoneObjectSelector({
className="mx-2 text-primary cursor-pointer" className="mx-2 text-primary cursor-pointer"
htmlFor="allLabels" htmlFor="allLabels"
> >
All Labels All Objects
</Label> </Label>
<Switch <Switch
className="ml-1" className="ml-1"
@ -160,25 +162,65 @@ export function ZoneObjectSelector({
); );
} }
const formSchema = z.object({
name: z.string().min(2, {
message: "Zone name must be at least 2 characters.",
}),
inertia: z.number(),
loitering_time: z.number(),
});
type ZoneEditPaneProps = { type ZoneEditPaneProps = {
polygons: Polygon[]; polygons: Polygon[];
activePolygonIndex?: number; activePolygonIndex?: number;
onCancel: () => void; onSave?: () => void;
onCancel?: () => void;
}; };
export function ZoneEditPane({ export function ZoneEditPane({
polygons, polygons,
activePolygonIndex, activePolygonIndex,
onSave,
onCancel, onCancel,
}: ZoneEditPaneProps) { }: ZoneEditPaneProps) {
const { data: config } = useSWR<FrigateConfig>("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 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) => {
return !polygons
.filter((polygon, index) => index !== activePolygonIndex)
.map((polygon) => polygon.name)
.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.",
}),
});
const polygon = useMemo(() => { const polygon = useMemo(() => {
if (polygons && activePolygonIndex !== undefined) { if (polygons && activePolygonIndex !== undefined) {
return polygons[activePolygonIndex]; return polygons[activePolygonIndex];
@ -189,15 +231,27 @@ export function ZoneEditPane({
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues: { defaultValues: {
name: "", name: polygon?.name ?? "",
inertia: 3, inertia:
loitering_time: 10, ((polygon &&
polygon.camera &&
polygon.name &&
config?.cameras[polygon.camera]?.zones[polygon.name]
?.inertia) as number) ?? 3,
loitering_time:
((polygon &&
polygon.camera &&
polygon.name &&
config?.cameras[polygon.camera]?.zones[polygon.name]
?.loitering_time) as number) ?? 0,
}, },
}); });
function onSubmit(values: z.infer<typeof formSchema>) { function onSubmit(values: z.infer<typeof formSchema>) {
console.log(values); console.log(values, polygons[activePolygonIndex]);
onSave();
} }
if (!polygon) { if (!polygon) {
@ -206,7 +260,7 @@ export function ZoneEditPane({
return ( return (
<> <>
<Heading as="h3">Edit Zone</Heading> <Heading as="h3">Zone</Heading>
<div className="flex my-3"> <div className="flex my-3">
<Separator className="bg-secondary" /> <Separator className="bg-secondary" />
</div> </div>
@ -220,10 +274,7 @@ export function ZoneEditPane({
<FormItem> <FormItem>
<FormLabel>Name</FormLabel> <FormLabel>Name</FormLabel>
<FormControl> <FormControl>
<Input <Input placeholder={polygon.name} {...field} />
placeholder={polygon.name ?? "Enter a name..."}
{...field}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -285,7 +336,57 @@ export function ZoneEditPane({
}} }}
/> />
</FormItem> </FormItem>
<div className="flex flex-row gap-2"> <div className="flex my-3">
<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="mx-2 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="mx-2 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>
<div className="flex flex-row gap-2 pt-5">
<Button className="flex flex-1" onClick={onCancel}> <Button className="flex flex-1" onClick={onCancel}>
Cancel Cancel
</Button> </Button>

View File

@ -1,7 +1,11 @@
export type PolygonType = "zone" | "motion_mask" | "object_mask";
export type Polygon = { export type Polygon = {
camera: string; camera: string;
name: string; name: string;
type: PolygonType;
points: number[][]; points: number[][];
isFinished: boolean; isFinished: boolean;
// isUnsaved: boolean;
color: number[]; color: number[];
}; };

View File

@ -108,7 +108,7 @@ export interface CameraConfig {
objects: { objects: {
filters: { filters: {
[objectName: string]: { [objectName: string]: {
mask: string | null; mask: string[] | null;
max_area: number; max_area: number;
max_ratio: number; max_ratio: number;
min_area: number; min_area: number;
@ -201,6 +201,7 @@ export interface CameraConfig {
coordinates: string; coordinates: string;
filters: Record<string, unknown>; filters: Record<string, unknown>;
inertia: number; inertia: number;
loitering_time: number;
objects: string[]; objects: string[];
color: number[]; color: number[];
}; };
@ -330,7 +331,7 @@ export interface FrigateConfig {
objects: { objects: {
filters: { filters: {
[objectName: string]: { [objectName: string]: {
mask: string | null; mask: string[] | null;
max_area: number; max_area: number;
max_ratio: number; max_ratio: number;
min_area: number; min_area: number;