working motion masks

This commit is contained in:
Josh Hawkins 2024-04-16 16:03:25 -05:00
parent 1982fa3461
commit 93b206d6b4
7 changed files with 275 additions and 67 deletions

View File

@ -4,7 +4,7 @@ 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, PolygonType } from "@/types/canvas"; import { Polygon, PolygonType } from "@/types/canvas";
import { interpolatePoints } from "@/utils/canvasUtil"; import { interpolatePoints, parseCoordinates } from "@/utils/canvasUtil";
import { Skeleton } from "../ui/skeleton"; import { Skeleton } from "../ui/skeleton";
import { useResizeObserver } from "@/hooks/resize-observer"; import { useResizeObserver } from "@/hooks/resize-observer";
import { LuExternalLink, LuInfo, LuPlus } from "react-icons/lu"; import { LuExternalLink, LuInfo, LuPlus } from "react-icons/lu";
@ -25,19 +25,6 @@ import ObjectMaskEditPane from "./ObjectMaskEditPane";
import PolygonItem from "./PolygonItem"; import PolygonItem from "./PolygonItem";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
const parseCoordinates = (coordinatesString: string) => {
const coordinates = coordinatesString.split(",");
const points = [];
for (let i = 0; i < coordinates.length; i += 2) {
const x = parseFloat(coordinates[i]);
const y = parseFloat(coordinates[i + 1]);
points.push([x, y]);
}
return points;
};
// export type ZoneObjects = { // export type ZoneObjects = {
// camera: string; // camera: string;
// zoneName: string; // zoneName: string;
@ -299,8 +286,9 @@ export default function MasksAndZones({
useEffect(() => { useEffect(() => {
if (cameraConfig && containerRef.current && scaledWidth && scaledHeight) { if (cameraConfig && containerRef.current && scaledWidth && scaledHeight) {
const zones = Object.entries(cameraConfig.zones).map( const zones = Object.entries(cameraConfig.zones).map(
([name, zoneData]) => ({ ([name, zoneData], index) => ({
type: "zone" as PolygonType, type: "zone" as PolygonType,
typeIndex: index,
camera: cameraConfig.name, camera: cameraConfig.name,
name, name,
objects: zoneData.objects, objects: zoneData.objects,
@ -317,28 +305,32 @@ export default function MasksAndZones({
}), }),
); );
const motionMasks = Object.entries(cameraConfig.motion.mask).map( // this can be an array or a string
([, maskData], index) => ({ const motionMasks = Object.entries(
type: "motion_mask" as PolygonType, Array.isArray(cameraConfig.motion.mask)
camera: cameraConfig.name, ? cameraConfig.motion.mask
name: `Motion Mask ${index + 1}`, : [cameraConfig.motion.mask],
objects: [], ).map(([, maskData], index) => ({
points: interpolatePoints( type: "motion_mask" as PolygonType,
parseCoordinates(maskData), typeIndex: index,
1, camera: cameraConfig.name,
1, name: `Motion Mask ${index + 1}`,
scaledWidth, objects: [],
scaledHeight, points: interpolatePoints(
), parseCoordinates(maskData),
isFinished: true, 1,
// isUnsaved: false, 1,
color: [0, 0, 255], scaledWidth,
}), scaledHeight,
); ),
isFinished: true,
color: [0, 0, 255],
}));
const globalObjectMasks = Object.entries(cameraConfig.objects.mask).map( const globalObjectMasks = Object.entries(cameraConfig.objects.mask).map(
([, maskData], index) => ({ ([, maskData], index) => ({
type: "object_mask" as PolygonType, type: "object_mask" as PolygonType,
typeIndex: index,
camera: cameraConfig.name, camera: cameraConfig.name,
name: `Object Mask ${index + 1} (all objects)`, name: `Object Mask ${index + 1} (all objects)`,
objects: [], objects: [],
@ -365,6 +357,7 @@ export default function MasksAndZones({
? [ ? [
{ {
type: "object_mask" as PolygonType, type: "object_mask" as PolygonType,
typeIndex: subIndex,
camera: cameraConfig.name, camera: cameraConfig.name,
name: `Object Mask ${globalObjectMasksCount + subIndex + 1} (${objectName})`, name: `Object Mask ${globalObjectMasksCount + subIndex + 1} (${objectName})`,
objects: [objectName], objects: [objectName],
@ -411,6 +404,10 @@ export default function MasksAndZones({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [cameraConfig, containerRef, scaledHeight, scaledWidth]); }, [cameraConfig, containerRef, scaledHeight, scaledWidth]);
useEffect(() => {
console.log("editing polygons changed:", editingPolygons);
}, [editingPolygons]);
useEffect(() => { useEffect(() => {
if (editPane === undefined) { if (editPane === undefined) {
setEditingPolygons([...allPolygons]); setEditingPolygons([...allPolygons]);
@ -469,6 +466,10 @@ export default function MasksAndZones({
polygons={editingPolygons} polygons={editingPolygons}
setPolygons={setEditingPolygons} setPolygons={setEditingPolygons}
activePolygonIndex={activePolygonIndex} activePolygonIndex={activePolygonIndex}
scaledWidth={scaledWidth}
scaledHeight={scaledHeight}
isLoading={isLoading}
setIsLoading={setIsLoading}
onCancel={handleCancel} onCancel={handleCancel}
onSave={handleSave} onSave={handleSave}
/> />
@ -478,6 +479,10 @@ export default function MasksAndZones({
polygons={editingPolygons} polygons={editingPolygons}
setPolygons={setEditingPolygons} setPolygons={setEditingPolygons}
activePolygonIndex={activePolygonIndex} activePolygonIndex={activePolygonIndex}
scaledWidth={scaledWidth}
scaledHeight={scaledHeight}
isLoading={isLoading}
setIsLoading={setIsLoading}
onCancel={handleCancel} onCancel={handleCancel}
onSave={handleSave} onSave={handleSave}
/> />

View File

@ -2,18 +2,32 @@ import Heading from "../ui/heading";
import { Separator } from "../ui/separator"; import { Separator } from "../ui/separator";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form"; import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form";
import { useMemo } from "react"; import { useCallback, useMemo } from "react";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { Polygon } from "@/types/canvas";
import PolygonEditControls from "./PolygonEditControls"; import PolygonEditControls from "./PolygonEditControls";
import { FaCheckCircle } from "react-icons/fa"; import { FaCheckCircle } from "react-icons/fa";
import { Polygon } from "@/types/canvas";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import {
flattenPoints,
interpolatePoints,
parseCoordinates,
} from "@/utils/canvasUtil";
import axios from "axios";
import { toast } from "sonner";
import { Toaster } from "../ui/sonner";
type MotionMaskEditPaneProps = { type MotionMaskEditPaneProps = {
polygons?: Polygon[]; polygons?: Polygon[];
setPolygons: React.Dispatch<React.SetStateAction<Polygon[]>>; setPolygons: React.Dispatch<React.SetStateAction<Polygon[]>>;
activePolygonIndex?: number; activePolygonIndex?: number;
scaledWidth?: number;
scaledHeight?: number;
isLoading: boolean;
setIsLoading: React.Dispatch<React.SetStateAction<boolean>>;
onSave?: () => void; onSave?: () => void;
onCancel?: () => void; onCancel?: () => void;
}; };
@ -22,9 +36,16 @@ export default function MotionMaskEditPane({
polygons, polygons,
setPolygons, setPolygons,
activePolygonIndex, activePolygonIndex,
scaledWidth,
scaledHeight,
isLoading,
setIsLoading,
onSave, onSave,
onCancel, onCancel,
}: MotionMaskEditPaneProps) { }: MotionMaskEditPaneProps) {
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const polygon = useMemo(() => { const polygon = useMemo(() => {
if (polygons && activePolygonIndex !== undefined) { if (polygons && activePolygonIndex !== undefined) {
return polygons[activePolygonIndex]; return polygons[activePolygonIndex];
@ -33,6 +54,12 @@ export default function MotionMaskEditPane({
} }
}, [polygons, activePolygonIndex]); }, [polygons, activePolygonIndex]);
const cameraConfig = useMemo(() => {
if (polygon?.camera && config) {
return config.cameras[polygon.camera];
}
}, [polygon, config]);
const defaultName = useMemo(() => { const defaultName = useMemo(() => {
if (!polygons) { if (!polygons) {
return; return;
@ -60,20 +87,121 @@ export default function MotionMaskEditPane({
}, },
}); });
function onSubmit(values: z.infer<typeof formSchema>) { const saveToConfig = useCallback(async () => {
// console.log("form values", values); if (!scaledWidth || !scaledHeight || !polygon || !cameraConfig) {
// if (activePolygonIndex === undefined || !polygons) { return;
// return; }
// } // console.log("loitering time", loitering_time);
// const alertsZones = config?.cameras[camera]?.review.alerts.required_zones;
// const updatedPolygons = [...polygons]; // const detectionsZones =
// const activePolygon = updatedPolygons[activePolygonIndex]; // config?.cameras[camera]?.review.detections.required_zones;
// updatedPolygons[activePolygonIndex] = {
// ...activePolygon, // console.log("out of try except", mutatedConfig);
// name: defaultName ?? "foo",
// }; const coordinates = flattenPoints(
// setPolygons(updatedPolygons); interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1),
).join(",");
let index = Array.isArray(cameraConfig.motion.mask)
? cameraConfig.motion.mask.length
: 1;
console.log("are we an array?", Array.isArray(cameraConfig.motion.mask));
console.log("index", index);
const editingMask = polygon.name.length > 0;
// editing existing mask, not creating a new one
if (editingMask) {
index = polygon.typeIndex;
if (polygon.name) {
const match = polygon.name.match(/\d+/);
if (match) {
// index = parseInt(match[0]) - 1;
console.log("editing, index", index);
}
}
}
const filteredMask = Array.isArray(cameraConfig.motion.mask)
? cameraConfig.motion.mask
: [cameraConfig.motion.mask].filter(
(_, currentIndex) => currentIndex !== index,
);
console.log("filtered", filteredMask);
// if (editingMask) {
// if (index != null) {
// }
// }
filteredMask.splice(index, 0, coordinates);
console.log("filtered after splice", filteredMask);
const queryString = filteredMask
.map((pointsArray) => {
const coordinates = flattenPoints(parseCoordinates(pointsArray)).join(
",",
);
return `cameras.${polygon?.camera}.motion.mask=${coordinates}&`;
})
.join("");
console.log("polygon", polygon);
console.log(queryString);
// console.log(
// `config/set?cameras.${polygon?.camera}.motion.mask=${coordinates}&${queryString}`,
// );
console.log("motion masks", cameraConfig.motion.mask);
console.log("new coords", coordinates);
// return;
axios
.put(`config/set?${queryString}`, {
requires_restart: 0,
})
.then((res) => {
if (res.status === 200) {
toast.success(`Zone ${name} saved.`, {
position: "top-center",
});
// setChangedValue(false);
updateConfig();
} else {
toast.error(`Failed to save config changes: ${res.statusText}`, {
position: "top-center",
});
}
})
.catch((error) => {
toast.error(
`Failed to save config changes: ${error.response.data.message}`,
{ position: "top-center" },
);
})
.finally(() => {
setIsLoading(false);
});
}, [updateConfig, polygon, scaledWidth, scaledHeight, setIsLoading]);
function onSubmit(values: z.infer<typeof formSchema>) {
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]); // console.log("active polygon", polygons[activePolygonIndex]);
saveToConfig();
if (onSave) { if (onSave) {
onSave(); onSave();
} }
@ -85,6 +213,7 @@ export default function MotionMaskEditPane({
return ( return (
<> <>
<Toaster position="top-center" />
<Heading as="h3" className="my-2"> <Heading as="h3" className="my-2">
{polygon.name.length ? "Edit" : "New"} Motion Mask {polygon.name.length ? "Edit" : "New"} Motion Mask
</Heading> </Heading>

View File

@ -20,7 +20,11 @@ import { FaDrawPolygon, FaObjectGroup } from "react-icons/fa";
import { BsPersonBoundingBox } from "react-icons/bs"; import { BsPersonBoundingBox } from "react-icons/bs";
import { HiOutlineDotsVertical, HiTrash } from "react-icons/hi"; import { HiOutlineDotsVertical, HiTrash } from "react-icons/hi";
import { isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
import { toRGBColorString } from "@/utils/canvasUtil"; import {
flattenPoints,
parseCoordinates,
toRGBColorString,
} from "@/utils/canvasUtil";
import { Polygon, PolygonType } from "@/types/canvas"; import { Polygon, PolygonType } from "@/types/canvas";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import axios from "axios"; import axios from "axios";
@ -33,6 +37,7 @@ import { reviewQueries } from "@/utils/zoneEdutUtil";
type PolygonItemProps = { type PolygonItemProps = {
polygon: Polygon; polygon: Polygon;
setAllPolygons: React.Dispatch<React.SetStateAction<Polygon[]>>; setAllPolygons: React.Dispatch<React.SetStateAction<Polygon[]>>;
setReindexPolygons: React.Dispatch<React.SetStateAction<Polygon[]>>;
index: number; index: number;
activePolygonIndex: number | undefined; activePolygonIndex: number | undefined;
hoveredPolygonIndex: number | null; hoveredPolygonIndex: number | null;
@ -86,13 +91,40 @@ export default function PolygonItem({
cameraConfig?.review.alerts.required_zones || [], cameraConfig?.review.alerts.required_zones || [],
cameraConfig?.review.detections.required_zones || [], cameraConfig?.review.detections.required_zones || [],
); );
url = `config/set?cameras.${polygon.camera}.zones.${polygon.name}${alertQueries}${detectionQueries}`; url = `cameras.${polygon.camera}.zones.${polygon.name}${alertQueries}${detectionQueries}`;
} }
if (polygon.type == "motion_mask") { if (polygon.type == "motion_mask") {
url = `config/set?cameras.${polygon.camera}.motion.mask`; console.log("deleting", polygon.typeIndex);
if (polygon.name) {
const match = polygon.name.match(/\d+/);
if (match) {
// index = parseInt(match[0]) - 1;
console.log("deleting, index", polygon.typeIndex);
}
}
const filteredMask = (
Array.isArray(cameraConfig.motion.mask)
? cameraConfig.motion.mask
: [cameraConfig.motion.mask]
).filter((_, currentIndex) => currentIndex !== polygon.typeIndex);
console.log(filteredMask);
url = filteredMask
.map((pointsArray) => {
const coordinates = flattenPoints(
parseCoordinates(pointsArray),
).join(",");
return `cameras.${polygon?.camera}.motion.mask=${coordinates}&`;
})
.join("");
console.log(url);
// return;
// url = `config/set?cameras.${polygon.camera}.motion.mask`;
} }
axios await axios
.put(url, { requires_restart: 0 }) .put(`config/set?${url}`, { requires_restart: 0 })
.then((res) => { .then((res) => {
if (res.status === 200) { if (res.status === 200) {
toast.success(`${polygon?.name} has been deleted.`, { toast.success(`${polygon?.name} has been deleted.`, {
@ -116,12 +148,35 @@ export default function PolygonItem({
// setIsLoading(false); // setIsLoading(false);
}); });
}, },
[updateConfig], [updateConfig, cameraConfig],
); );
const handleDelete = (index: number) => { const reindexPolygons = (arr: Polygon[]): Polygon[] => {
const typeCounters: { [type: string]: number } = {};
return arr.map((obj) => {
if (!typeCounters[obj.type]) {
typeCounters[obj.type] = 0;
}
const newObj: Polygon = {
...obj,
typeIndex: typeCounters[obj.type],
};
typeCounters[obj.type]++;
return newObj;
});
};
const handleDelete = (type: string, typeIndex: number) => {
setAllPolygons((oldPolygons) => { setAllPolygons((oldPolygons) => {
return oldPolygons.filter((_, i) => i !== index); const filteredPolygons = oldPolygons.filter(
(polygon) =>
!(polygon.type === type && polygon.typeIndex === typeIndex),
);
console.log("filtered", filteredPolygons);
// console.log("reindexed", reindexPolygons(filteredPolygons));
return filteredPolygons;
}); });
setActivePolygonIndex(undefined); setActivePolygonIndex(undefined);
saveToConfig(polygon); saveToConfig(polygon);
@ -176,7 +231,9 @@ export default function PolygonItem({
</AlertDialogDescription> </AlertDialogDescription>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => handleDelete(index)}> <AlertDialogAction
onClick={() => handleDelete(polygon.type, polygon.typeIndex)}
>
Delete Delete
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>

View File

@ -18,7 +18,7 @@ import { isMobile } from "react-device-detect";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { Polygon } from "@/types/canvas"; import { FormValuesType, Polygon } from "@/types/canvas";
import { reviewQueries } from "@/utils/zoneEdutUtil"; import { reviewQueries } from "@/utils/zoneEdutUtil";
import { Switch } from "../ui/switch"; import { Switch } from "../ui/switch";
import { Label } from "../ui/label"; import { Label } from "../ui/label";
@ -158,15 +158,6 @@ export default function ZoneEditPane({
}); });
// const [changedValue, setChangedValue] = useState(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( // const requiredDetectionZones = useMemo(
// () => cameraConfig?.review.detections.required_zones, // () => cameraConfig?.review.detections.required_zones,

View File

@ -56,9 +56,11 @@ export default function Settings() {
useEffect(() => { useEffect(() => {
if (cameras) { if (cameras) {
// TODO: fixme
setSelectedCamera(cameras[0].name); setSelectedCamera(cameras[0].name);
console.log("setting selected cam");
} }
}, [cameras]); }, []);
return ( return (
<div className="size-full p-2 flex flex-col"> <div className="size-full p-2 flex flex-col">

View File

@ -1,6 +1,7 @@
export type PolygonType = "zone" | "motion_mask" | "object_mask"; export type PolygonType = "zone" | "motion_mask" | "object_mask";
export type Polygon = { export type Polygon = {
typeIndex: number;
camera: string; camera: string;
name: string; name: string;
type: PolygonType; type: PolygonType;
@ -10,3 +11,13 @@ export type Polygon = {
// isUnsaved: boolean; // isUnsaved: boolean;
color: number[]; color: number[];
}; };
export type FormValuesType = {
name: string;
inertia: number;
loitering_time: number;
isFinished: boolean;
objects: string[];
review_alerts: boolean;
review_detections: boolean;
};

View File

@ -64,6 +64,19 @@ export const interpolatePoints = (
return newPoints; return newPoints;
}; };
export const parseCoordinates = (coordinatesString: string) => {
const coordinates = coordinatesString.split(",");
const points = [];
for (let i = 0; i < coordinates.length; i += 2) {
const x = parseFloat(coordinates[i]);
const y = parseFloat(coordinates[i + 1]);
points.push([x, y]);
}
return points;
};
export const flattenPoints = (points: number[][]): number[] => { export const flattenPoints = (points: number[][]): number[] => {
return points.reduce((acc, point) => [...acc, ...point], []); return points.reduce((acc, point) => [...acc, ...point], []);
}; };