object and motion edit panes

This commit is contained in:
Josh Hawkins 2024-04-14 20:57:00 -05:00
parent 6ae3e24157
commit 673541b2b9
6 changed files with 417 additions and 12 deletions

View File

@ -15,7 +15,6 @@ import { HiTrash } from "react-icons/hi";
import copy from "copy-to-clipboard";
import { toast } from "sonner";
import { Toaster } from "../ui/sonner";
import { ZoneEditPane } from "./ZoneEditPane";
import { Button } from "../ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import {
@ -29,6 +28,9 @@ import {
AlertDialogTitle,
} from "../ui/alert-dialog";
import Heading from "../ui/heading";
import ZoneEditPane from "./ZoneEditPane";
import MotionMaskEditPane from "./MotionMaskEditPane";
import ObjectMaskEditPane from "./ObjectMaskEditPane";
const parseCoordinates = (coordinatesString: string) => {
const coordinates = coordinatesString.split(",");
@ -232,7 +234,28 @@ export default function MasksAndZones({
}, [scaledHeight, aspectRatio]);
const handleNewPolygon = (type: PolygonType) => {
if (!cameraConfig) {
return;
}
setActivePolygonIndex(allPolygons.length);
let polygonName = "";
let polygonColor = [128, 128, 0];
if (type == "motion_mask") {
const count = allPolygons.filter(
(poly) => poly.type == "motion_mask",
).length;
polygonName = `Motion Mask ${count + 1}`;
polygonColor = [0, 0, 220];
}
if (type == "object_mask") {
const count = allPolygons.filter(
(poly) => poly.type == "object_mask",
).length;
polygonName = `Object Mask ${count + 1}`;
polygonColor = [128, 128, 128];
// TODO - get this from config object after mutation so label can be set
}
setEditingPolygons([
...(allPolygons || []),
{
@ -240,10 +263,10 @@ export default function MasksAndZones({
isFinished: false,
isUnsaved: true,
type,
name: "",
name: polygonName,
objects: [],
camera: selectedCamera,
color: [0, 0, 220],
color: polygonColor,
},
]);
};
@ -330,7 +353,7 @@ export default function MasksAndZones({
([, maskData], index) => ({
type: "object_mask" as PolygonType,
camera: cameraConfig.name,
name: `All Objects Object Mask ${index + 1}`,
name: `Object Mask ${index + 1} (all objects)`,
objects: [],
points: interpolatePoints(
parseCoordinates(maskData),
@ -356,7 +379,7 @@ export default function MasksAndZones({
{
type: "object_mask" as PolygonType,
camera: cameraConfig.name,
name: `${objectName.charAt(0).toUpperCase() + objectName.slice(1)} Object Mask ${globalObjectMasksCount + subIndex + 1}`,
name: `Object Mask ${globalObjectMasksCount + subIndex + 1} (${objectName})`,
objects: [objectName],
points: interpolatePoints(
parseCoordinates(maskItem),
@ -451,7 +474,7 @@ export default function MasksAndZones({
/>
)}
{editPane == "motion_mask" && (
<ZoneEditPane
<MotionMaskEditPane
polygons={editingPolygons}
setPolygons={setEditingPolygons}
activePolygonIndex={activePolygonIndex}
@ -460,7 +483,7 @@ export default function MasksAndZones({
/>
)}
{editPane == "object_mask" && (
<ZoneEditPane
<ObjectMaskEditPane
polygons={editingPolygons}
setPolygons={setEditingPolygons}
activePolygonIndex={activePolygonIndex}

View File

@ -0,0 +1,125 @@
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 { ATTRIBUTES, FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr";
import { isMobile } from "react-device-detect";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Polygon } from "@/types/canvas";
import { Switch } from "../ui/switch";
import { Label } from "../ui/label";
import PolygonEditControls from "./PolygonEditControls";
type MotionMaskEditPaneProps = {
polygons?: Polygon[];
setPolygons: React.Dispatch<React.SetStateAction<Polygon[]>>;
activePolygonIndex?: number;
onSave?: () => void;
onCancel?: () => void;
};
export default function MotionMaskEditPane({
polygons,
setPolygons,
activePolygonIndex,
onSave,
onCancel,
}: MotionMaskEditPaneProps) {
const polygon = useMemo(() => {
if (polygons && activePolygonIndex !== undefined) {
return polygons[activePolygonIndex];
} else {
return null;
}
}, [polygons, activePolygonIndex]);
const formSchema = z
.object({
polygon: z.object({ isFinished: z.boolean() }),
})
.refine(() => polygon?.isFinished === true, {
message: "The polygon drawing must be finished before saving.",
path: ["polygon.isFinished"],
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues: {
polygon: { isFinished: polygon?.isFinished ?? false },
},
});
function onSubmit(values: z.infer<typeof formSchema>) {
console.log("form values", values);
console.log("active polygon", polygons[activePolygonIndex]);
// make sure polygon isFinished
onSave();
}
if (!polygon) {
return;
}
return (
<>
<Heading as="h3" className="my-2">
Motion Mask
</Heading>
<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>
{polygons[activePolygonIndex].isFinished ? <></> : <></>}
<PolygonEditControls
polygons={polygons}
setPolygons={setPolygons}
activePolygonIndex={activePolygonIndex}
/>
</div>
)}
<div className="mb-3 text-sm text-muted-foreground">
Click to draw a polygon on the image.
</div>
<Separator className="my-3 bg-secondary" />
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="polygon.isFinished"
render={() => (
<FormItem>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-row gap-2 pt-5">
<Button className="flex flex-1" onClick={onCancel}>
Cancel
</Button>
<Button variant="select" className="flex flex-1" type="submit">
Save
</Button>
</div>
</form>
</Form>
</>
);
}

View File

@ -0,0 +1,247 @@
import Heading from "../ui/heading";
import { Separator } from "../ui/separator";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { useEffect, useMemo, useState } from "react";
import { ATTRIBUTES, FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr";
import { isMobile } from "react-device-detect";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Polygon } from "@/types/canvas";
import PolygonEditControls from "./PolygonEditControls";
type ObjectMaskEditPaneProps = {
polygons?: Polygon[];
setPolygons: React.Dispatch<React.SetStateAction<Polygon[]>>;
activePolygonIndex?: number;
onSave?: () => void;
onCancel?: () => void;
};
export default function ObjectMaskEditPane({
polygons,
setPolygons,
activePolygonIndex,
onSave,
onCancel,
}: ObjectMaskEditPaneProps) {
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 polygon = useMemo(() => {
if (polygons && activePolygonIndex !== undefined) {
return polygons[activePolygonIndex];
} else {
return null;
}
}, [polygons, activePolygonIndex]);
const formSchema = z
.object({
polygon: z.object({ isFinished: z.boolean() }),
})
.refine(() => polygon?.isFinished === true, {
message: "The polygon drawing must be finished before saving.",
path: ["polygon.isFinished"],
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues: {
polygon: { isFinished: polygon?.isFinished ?? false },
},
});
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 (!polygon) {
return;
}
return (
<>
<Heading as="h3" className="my-2">
Object Mask
</Heading>
<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>
{polygons[activePolygonIndex].isFinished ? <></> : <></>}
<PolygonEditControls
polygons={polygons}
setPolygons={setPolygons}
activePolygonIndex={activePolygonIndex}
/>
</div>
)}
<div className="mb-3 text-sm text-muted-foreground">
Click to draw a polygon on the image.
</div>
<Separator className="my-3 bg-secondary" />
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormItem>
<FormLabel>Objects</FormLabel>
<FormDescription>
The object type that that applies to this object mask.
</FormDescription>
<ZoneObjectSelector
camera={polygon.camera}
zoneName={polygon.name}
updateLabelFilter={(objects) => {
// console.log(objects);
}}
/>
</FormItem>
<FormField
control={form.control}
name="polygon.isFinished"
render={() => (
<FormItem>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-row gap-2 pt-5">
<Button className="flex flex-1" onClick={onCancel}>
Cancel
</Button>
<Button variant="select" className="flex flex-1" type="submit">
Save
</Button>
</div>
</form>
</Form>
</>
);
}
type ZoneObjectSelectorProps = {
camera: string;
zoneName: string;
updateLabelFilter: (labels: string[] | undefined) => void;
};
export function ZoneObjectSelector({
camera,
zoneName,
updateLabelFilter,
}: ZoneObjectSelectorProps) {
const { data: config } = useSWR<FrigateConfig>("config");
const cameraConfig = useMemo(() => {
if (config && camera) {
return config.cameras[camera];
}
}, [config, camera]);
const allLabels = useMemo<string[]>(() => {
if (!config) {
return [];
}
const labels = new Set<string>();
Object.values(config.cameras).forEach((camera) => {
camera.objects.track.forEach((label) => {
if (!ATTRIBUTES.includes(label)) {
labels.add(label);
}
});
});
return [...labels].sort();
}, [config]);
const cameraLabels = useMemo<string[]>(() => {
if (!cameraConfig) {
return [];
}
const labels = new Set<string>();
cameraConfig.objects.track.forEach((label) => {
if (!ATTRIBUTES.includes(label)) {
labels.add(label);
}
});
return [...labels].sort() || [];
}, [cameraConfig, zoneName]);
const [currentLabels, setCurrentLabels] = useState<string[] | undefined>(
cameraLabels.every((label, index) => label === allLabels[index])
? undefined
: cameraLabels,
);
useEffect(() => {
updateLabelFilter(currentLabels);
}, [currentLabels, updateLabelFilter]);
return (
<>
<div className="h-auto overflow-y-auto overflow-x-hidden">
<div className="my-2.5 flex flex-col gap-2.5">
<Select>
<SelectTrigger>
<SelectValue placeholder="Select object type" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all_labels">All object types</SelectItem>
<SelectSeparator className="bg-secondary" />
{allLabels.map((item) => (
<SelectItem key={item} value={item}>
{item.replaceAll("_", " ").charAt(0).toUpperCase() +
item.slice(1)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
</>
);
}

View File

@ -31,7 +31,7 @@ type ZoneEditPaneProps = {
onCancel?: () => void;
};
export function ZoneEditPane({
export default function ZoneEditPane({
polygons,
setPolygons,
activePolygonIndex,

View File

@ -11,7 +11,7 @@ import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
import MotionTuner from "@/components/settings/MotionTuner";
import MasksAndZones from "@/components/settings/MasksAndZones";
import { Button } from "@/components/ui/button";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import useOptimisticState from "@/hooks/use-optimistic-state";
import Logo from "@/components/Logo";
import { isMobile } from "react-device-detect";
@ -50,10 +50,16 @@ export default function Settings() {
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]);
const [selectedCamera, setSelectedCamera] = useState(cameras[0].name);
const [selectedCamera, setSelectedCamera] = useState<string>();
const [filterZoneMask, setFilterZoneMask] = useState<PolygonType[]>();
useEffect(() => {
if (cameras) {
setSelectedCamera(cameras[0].name);
}
}, [cameras]);
return (
<div className="size-full p-2 flex flex-col">
<div className="w-full h-11 relative flex justify-between items-center">
@ -135,6 +141,10 @@ function CameraSelectButton({
}: CameraSelectButtonProps) {
const [open, setOpen] = useState(false);
if (!allCameras) {
return;
}
const trigger = (
<Button
className="flex items-center gap-2 capitalize bg-selected hover:bg-selected"

View File

@ -133,8 +133,8 @@
--secondary-highlight: hsl(0, 0%, 25%);
--secondary-highlight: 0 0% 25%;
--muted: hsl(0, 0%, 15%);
--muted: 0 0% 15%;
--muted: hsl(0, 0%, 12%);
--muted: 0 0% 12%;
--muted-foreground: hsl(0, 0%, 32%);
--muted-foreground: 0 0% 32%;