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 useSWR from "swr";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { PolygonCanvas } from "./PolygonCanvas";
import { Polygon } from "@/types/canvas";
import { Polygon, PolygonType } from "@/types/canvas";
import { interpolatePoints, toRGBColorString } from "@/utils/canvasUtil";
import { isDesktop } from "react-device-detect";
import { NewZoneButton } from "./NewZoneButton";
import { isDesktop, isMobile } from "react-device-detect";
import { Skeleton } from "../ui/skeleton";
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 copy from "copy-to-clipboard";
import { toast } from "sonner";
import { Toaster } from "../ui/sonner";
import Heading from "../ui/heading";
import { Input } from "../ui/input";
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 coordinates = coordinatesString.split(",");
@ -33,6 +39,139 @@ const parseCoordinates = (coordinatesString: string) => {
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 = {
camera: string;
zoneName: string;
@ -41,24 +180,30 @@ export type ZoneObjects = {
type MasksAndZoneProps = {
selectedCamera: string;
setSelectedCamera: React.Dispatch<React.SetStateAction<string>>;
};
export default function MasksAndZones({
selectedCamera,
setSelectedCamera,
}: MasksAndZoneProps) {
export default function MasksAndZones({ selectedCamera }: MasksAndZoneProps) {
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 [activePolygonIndex, setActivePolygonIndex] = useState<
number | undefined
>(undefined);
const [hoveredPolygonIndex, setHoveredPolygonIndex] = useState<number | null>(
null,
);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
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];
const [editPane, setEditPane] = useState<EditPaneType>(undefined);
// type EditPaneType = (typeof polygonTypes)[number];
const [editPane, setEditPane] = useState<PolygonType | undefined>(undefined);
const cameras = useMemo(() => {
if (!config) {
@ -121,95 +266,77 @@ export default function MasksAndZones({
[setZoneObjects],
);
const growe = useMemo(() => {
if (!cameraConfig) {
return;
}
// const getCameraAspect = useCallback(
// (cam: string) => {
// if (!config) {
// return undefined;
// }
const aspectRatio = cameraConfig.detect.width / cameraConfig.detect.height;
// const camera = config.cameras[cam];
if (aspectRatio > 2) {
return "aspect-wide";
} else if (aspectRatio < 16 / 9) {
if (isDesktop) {
return "size-full aspect-tall";
} else {
return "size-full";
}
} else {
return "size-full aspect-video";
}
}, [cameraConfig]);
// if (!camera) {
// return undefined;
// }
const getCameraAspect = useCallback(
(cam: string) => {
// return camera.detect.width / camera.detect.height;
// },
// [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) {
return undefined;
}
const camera = config.cameras[cam];
const camera = config.cameras[selectedCamera];
if (!camera) {
return undefined;
}
return camera.detect.width / camera.detect.height;
},
[config],
);
}, [config, selectedCamera]);
const mainCameraAspect = useMemo(() => {
const aspectRatio = getCameraAspect(selectedCamera);
if (!aspectRatio) {
return "normal";
} else if (aspectRatio > 2) {
return "wide";
} else if (aspectRatio < 16 / 9) {
return "tall";
} else {
return "normal";
const detectHeight = useMemo(() => {
if (!config) {
return undefined;
}
}, [getCameraAspect, selectedCamera]);
const grow = useMemo(() => {
if (mainCameraAspect == "wide") {
return "w-full aspect-wide";
} else if (mainCameraAspect == "tall") {
if (isDesktop) {
return "size-full aspect-tall flex flex-col justify-center";
} else {
return "size-full";
const camera = config.cameras[selectedCamera];
if (!camera) {
return undefined;
}
} else {
return "w-full aspect-video";
}
}, [mainCameraAspect]);
const [{ width: containerWidth, height: containerHeight }] =
useResizeObserver(containerRef);
const { width, height } = cameraConfig
? cameraConfig.detect
: { width: 1, height: 1 };
const aspectRatio = width / height;
return camera.detect.height;
}, [config, selectedCamera]);
const stretch = true;
const fitAspect = 16 / 9;
// console.log(containerRef.current?.clientHeight);
const fitAspect = 1;
const scaledHeight = useMemo(() => {
if (containerRef.current && aspectRatio && detectHeight) {
console.log("recalc", Date.now());
const scaledHeight =
aspectRatio < (fitAspect ?? 0)
? Math.floor(
Math.min(containerHeight, containerRef.current?.clientHeight),
)
: Math.floor(containerWidth / aspectRatio);
const finalHeight = stretch ? scaledHeight : Math.min(scaledHeight, height);
const finalHeight = stretch
? scaledHeight
: Math.min(scaledHeight, detectHeight);
if (finalHeight > 0) {
return finalHeight;
}
}
return 100;
}, [
@ -217,19 +344,52 @@ export default function MasksAndZones({
containerWidth,
containerHeight,
fitAspect,
height,
detectHeight,
stretch,
]);
const scaledWidth = useMemo(
() => Math.ceil(scaledHeight * aspectRatio),
[scaledHeight, aspectRatio],
);
const scaledWidth = useMemo(() => {
if (aspectRatio && scaledHeight) {
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(
(index: number) => {
if (zonePolygons) {
const poly = zonePolygons[index];
if (allPolygons && scaledWidth) {
const poly = allPolygons[index];
copy(
interpolatePoints(poly.points, scaledWidth, scaledHeight, 1, 1)
.map((point) => `${point[0]},${point[1]}`)
@ -240,13 +400,14 @@ export default function MasksAndZones({
toast.error("Could not copy coordinates to clipboard.");
}
},
[zonePolygons, scaledHeight, scaledWidth],
[allPolygons, scaledHeight, scaledWidth],
);
useEffect(() => {
if (cameraConfig && containerRef.current) {
setZonePolygons(
Object.entries(cameraConfig.zones).map(([name, zoneData]) => ({
if (cameraConfig && containerRef.current && scaledWidth) {
setAllPolygons([
...Object.entries(cameraConfig.zones).map(([name, zoneData]) => ({
type: "zone" as PolygonType, // Add the type property here
camera: cameraConfig.name,
name,
points: interpolatePoints(
@ -259,7 +420,46 @@ export default function MasksAndZones({
isFinished: true,
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(
Object.entries(cameraConfig.zones).map(([name, zoneData]) => ({
@ -273,6 +473,13 @@ export default function MasksAndZones({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cameraConfig, containerRef]);
useEffect(() => {
if (editPane === undefined) {
setEditingPolygons([...allPolygons]);
console.log(allPolygons);
}
}, [setEditingPolygons, allPolygons, editPane]);
// useEffect(() => {
// console.log(
// "config zone objects",
@ -297,119 +504,132 @@ export default function MasksAndZones({
return (
<>
{cameraConfig && zonePolygons && (
{cameraConfig && allPolygons && (
<div className="flex flex-col md:flex-row size-full">
<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 mb-3">
<Separator />
</div> */}
{editPane == "zone" && (
<ZoneEditPane
polygons={zonePolygons}
polygons={allPolygons}
activePolygonIndex={activePolygonIndex}
onCancel={() => {
setEditPane(undefined);
setActivePolygonIndex(undefined);
}}
onCancel={handleCancel}
onSave={handleSave}
/>
)}
{editPane == "motion_mask" && (
<ZoneEditPane
polygons={zonePolygons}
polygons={allPolygons}
activePolygonIndex={activePolygonIndex}
onCancel={() => {
setEditPane(undefined);
setActivePolygonIndex(undefined);
}}
onCancel={handleCancel}
/>
)}
{editPane == "object_mask" && (
<ZoneEditPane
polygons={zonePolygons}
polygons={allPolygons}
activePolygonIndex={activePolygonIndex}
onCancel={() => {
setEditPane(undefined);
setActivePolygonIndex(undefined);
}}
onCancel={handleCancel}
/>
)}
{editPane == undefined && (
<>
<div className="flex flex-row justify-between items-center mb-3">
<div className="text-md">Zones</div>
<NewZoneButton
camera={cameraConfig.name}
polygons={zonePolygons}
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"
<Button
variant="ghost"
className="h-8 px-0"
onClick={() => {
setActivePolygonIndex(index);
setEditPane("zone");
// if (activePolygonIndex == index) {
// setActivePolygonIndex(null);
// } else {
// setActivePolygonIndex(index);
// }
handleNewPolygon("zone");
}}
>
<LuPencil
className={`size-4 ${activePolygonIndex === index ? "text-primary" : "text-muted-foreground"}`}
/>
<LuPlusSquare />
</Button>
</div>
<div
className="cursor-pointer"
onClick={() => handleCopyCoordinates(index)}
>
<LuCopy
className={`size-4 ${activePolygonIndex === index ? "text-primary" : "text-muted-foreground"}`}
{allPolygons
.flatMap((polygon, index) =>
polygon.type === "zone" ? [{ 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>
<div
className="cursor-pointer"
))}
<div className="flex flex-row justify-between items-center my-3">
<div className="text-md">Motion Masks</div>
<Button
variant="ghost"
className="h-8 px-0"
onClick={() => {
setZonePolygons((oldPolygons) => {
return oldPolygons.filter((_, i) => i !== index);
});
setActivePolygonIndex(undefined);
setEditPane("motion_mask");
handleNewPolygon("motion_mask");
}}
>
<LuTrash
className={`size-4 ${activePolygonIndex === index ? "text-primary fill-primary" : "text-muted-foreground fill-muted-foreground"}`}
<LuPlusSquare />
</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
.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>
</TableHeader>
<TableBody>
{zonePolygons.map((polygon, index) => (
{allPolygons.map((polygon, index) => (
<TableRow key={index}>
<TableCell className="font-medium">
{polygon.name}
@ -469,16 +689,16 @@ export default function MasksAndZones({
</div>
<ZoneControls
camera={cameraConfig.name}
polygons={zonePolygons}
setPolygons={setZonePolygons}
polygons={allPolygons}
setPolygons={setAllPolygons}
activePolygonIndex={activePolygonIndex}
setActivePolygonIndex={setActivePolygonIndex}
/>
<div className="flex flex-col justify-center items-center m-auto w-[30%] bg-secondary">
<pre style={{ whiteSpace: "pre-wrap" }}>
{JSON.stringify(
zonePolygons &&
zonePolygons.map((polygon) =>
allPolygons &&
allPolygons.map((polygon) =>
interpolatePoints(
polygon.points,
scaledWidth,
@ -503,9 +723,11 @@ export default function MasksAndZones({
camera={cameraConfig.name}
width={scaledWidth}
height={scaledHeight}
polygons={zonePolygons}
setPolygons={setZonePolygons}
scale={1}
polygons={editingPolygons}
setPolygons={setEditingPolygons}
activePolygonIndex={activePolygonIndex}
hoveredPolygonIndex={hoveredPolygonIndex}
/>
) : (
<Skeleton className="w-full h-full" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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