working zone edit pane

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

View File

@ -587,6 +587,14 @@ class ZoneConfig(BaseModel):
def contour(self) -> np.ndarray: def contour(self) -> np.ndarray:
return self._contour return self._contour
@field_validator("objects", mode="before")
@classmethod
def validate_objects(cls, v):
if isinstance(v, str) and "," not in v:
return [v]
return v
def __init__(self, **config): def __init__(self, **config):
super().__init__(**config) super().__init__(**config)
@ -667,6 +675,14 @@ class AlertsConfig(FrigateBaseModel):
title="List of required zones to be entered in order to save the event as an alert.", title="List of required zones to be entered in order to save the event as an alert.",
) )
@field_validator("required_zones", mode="before")
@classmethod
def validate_required_zones(cls, v):
if isinstance(v, str) and "," not in v:
return [v]
return v
class DetectionsConfig(FrigateBaseModel): class DetectionsConfig(FrigateBaseModel):
"""Configure detections""" """Configure detections"""
@ -679,6 +695,14 @@ class DetectionsConfig(FrigateBaseModel):
title="List of required zones to be entered in order to save the event as a detection.", title="List of required zones to be entered in order to save the event as a detection.",
) )
@field_validator("required_zones", mode="before")
@classmethod
def validate_required_zones(cls, v):
if isinstance(v, str) and "," not in v:
return [v]
return v
class ReviewConfig(FrigateBaseModel): class ReviewConfig(FrigateBaseModel):
"""Configure reviews""" """Configure reviews"""

View File

@ -64,6 +64,7 @@ export default function MasksAndZones({
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const [allPolygons, setAllPolygons] = useState<Polygon[]>([]); const [allPolygons, setAllPolygons] = useState<Polygon[]>([]);
const [editingPolygons, setEditingPolygons] = useState<Polygon[]>([]); const [editingPolygons, setEditingPolygons] = useState<Polygon[]>([]);
const [isLoading, setIsLoading] = useState(false);
// const [zoneObjects, setZoneObjects] = useState<ZoneObjects[]>([]); // const [zoneObjects, setZoneObjects] = useState<ZoneObjects[]>([]);
const [activePolygonIndex, setActivePolygonIndex] = useState< const [activePolygonIndex, setActivePolygonIndex] = useState<
number | undefined number | undefined
@ -219,31 +220,24 @@ export default function MasksAndZones({
} }
setActivePolygonIndex(allPolygons.length); setActivePolygonIndex(allPolygons.length);
let polygonName = "";
let polygonColor = [128, 128, 0]; let polygonColor = [128, 128, 0];
if (type == "motion_mask") { if (type == "motion_mask") {
const count = allPolygons.filter(
(poly) => poly.type == "motion_mask",
).length;
polygonName = `Motion Mask ${count + 1}`;
polygonColor = [0, 0, 220]; polygonColor = [0, 0, 220];
} }
if (type == "object_mask") { if (type == "object_mask") {
const count = allPolygons.filter(
(poly) => poly.type == "object_mask",
).length;
polygonName = `Object Mask ${count + 1}`;
polygonColor = [128, 128, 128]; polygonColor = [128, 128, 128];
// TODO - get this from config object after mutation so label can be set // TODO - get this from config object after mutation so label can be set
} }
setEditingPolygons([ setEditingPolygons([
...(allPolygons || []), ...(allPolygons || []),
{ {
points: [], points: [],
isFinished: false, isFinished: false,
isUnsaved: true, // isUnsaved: true,
type, type,
name: polygonName, name: "",
objects: [], objects: [],
camera: selectedCamera, camera: selectedCamera,
color: polygonColor, color: polygonColor,
@ -265,11 +259,24 @@ export default function MasksAndZones({
const handleSave = useCallback(() => { const handleSave = useCallback(() => {
// console.log("handling save"); // console.log("handling save");
setAllPolygons([...(editingPolygons ?? [])]); setAllPolygons([...(editingPolygons ?? [])]);
setActivePolygonIndex(undefined);
setEditPane(undefined); // setEditPane(undefined);
setHoveredPolygonIndex(null); setHoveredPolygonIndex(null);
}, [editingPolygons]); }, [editingPolygons]);
useEffect(() => {
console.log(isLoading);
console.log("edit pane", editPane);
if (isLoading) {
return;
}
if (!isLoading && editPane !== undefined) {
console.log("setting");
setActivePolygonIndex(undefined);
setEditPane(undefined);
}
}, [isLoading]);
const handleCopyCoordinates = useCallback( const handleCopyCoordinates = useCallback(
(index: number) => { (index: number) => {
if (allPolygons && scaledWidth && scaledHeight) { if (allPolygons && scaledWidth && scaledHeight) {
@ -287,7 +294,7 @@ export default function MasksAndZones({
[allPolygons, scaledHeight, scaledWidth], [allPolygons, scaledHeight, scaledWidth],
); );
useEffect(() => {}, [editPane]); // useEffect(() => {}, [editPane]);
useEffect(() => { useEffect(() => {
if (cameraConfig && containerRef.current && scaledWidth && scaledHeight) { if (cameraConfig && containerRef.current && scaledWidth && scaledHeight) {
@ -305,7 +312,7 @@ export default function MasksAndZones({
scaledHeight, scaledHeight,
), ),
isFinished: true, isFinished: true,
isUnsaved: false, // isUnsaved: false,
color: zoneData.color, color: zoneData.color,
}), }),
); );
@ -324,7 +331,7 @@ export default function MasksAndZones({
scaledHeight, scaledHeight,
), ),
isFinished: true, isFinished: true,
isUnsaved: false, // isUnsaved: false,
color: [0, 0, 255], color: [0, 0, 255],
}), }),
); );
@ -343,7 +350,7 @@ export default function MasksAndZones({
scaledHeight, scaledHeight,
), ),
isFinished: true, isFinished: true,
isUnsaved: false, // isUnsaved: false,
color: [0, 0, 255], color: [0, 0, 255],
}), }),
); );
@ -369,7 +376,7 @@ export default function MasksAndZones({
scaledHeight, scaledHeight,
), ),
isFinished: true, isFinished: true,
isUnsaved: false, // isUnsaved: false,
color: [128, 128, 128], color: [128, 128, 128],
}, },
] ]
@ -449,6 +456,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

@ -1,27 +1,14 @@
import Heading from "../ui/heading"; 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 { import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form";
Form, import { useMemo } from "react";
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useEffect, useMemo, useState } from "react";
import { ATTRIBUTE_LABELS, FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr";
import { isMobile } from "react-device-detect";
import { 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 { Polygon } from "@/types/canvas";
import { Switch } from "../ui/switch";
import { Label } from "../ui/label";
import PolygonEditControls from "./PolygonEditControls"; import PolygonEditControls from "./PolygonEditControls";
import { FaCheckCircle } from "react-icons/fa";
type MotionMaskEditPaneProps = { type MotionMaskEditPaneProps = {
polygons?: Polygon[]; polygons?: Polygon[];
@ -46,9 +33,19 @@ export default function MotionMaskEditPane({
} }
}, [polygons, activePolygonIndex]); }, [polygons, activePolygonIndex]);
const defaultName = useMemo(() => {
if (!polygons) {
return;
}
const count = polygons.filter((poly) => poly.type == "motion_mask").length;
return `Motion Mask ${count + 1}`;
}, [polygons]);
const formSchema = z const formSchema = z
.object({ .object({
polygon: z.object({ isFinished: z.boolean() }), polygon: z.object({ name: z.string(), isFinished: z.boolean() }),
}) })
.refine(() => polygon?.isFinished === true, { .refine(() => polygon?.isFinished === true, {
message: "The polygon drawing must be finished before saving.", message: "The polygon drawing must be finished before saving.",
@ -59,16 +56,28 @@ export default function MotionMaskEditPane({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
mode: "onChange", mode: "onChange",
defaultValues: { defaultValues: {
polygon: { isFinished: polygon?.isFinished ?? false }, polygon: { isFinished: polygon?.isFinished ?? false, name: defaultName },
}, },
}); });
function onSubmit(values: z.infer<typeof formSchema>) { function onSubmit(values: z.infer<typeof formSchema>) {
console.log("form values", values); // console.log("form values", values);
console.log("active polygon", polygons[activePolygonIndex]); // if (activePolygonIndex === undefined || !polygons) {
// make sure polygon isFinished // return;
// }
// const updatedPolygons = [...polygons];
// const activePolygon = updatedPolygons[activePolygonIndex];
// updatedPolygons[activePolygonIndex] = {
// ...activePolygon,
// name: defaultName ?? "foo",
// };
// setPolygons(updatedPolygons);
// console.log("active polygon", polygons[activePolygonIndex]);
if (onSave) {
onSave(); onSave();
} }
}
if (!polygon) { if (!polygon) {
return; return;
@ -77,15 +86,28 @@ export default function MotionMaskEditPane({
return ( return (
<> <>
<Heading as="h3" className="my-2"> <Heading as="h3" className="my-2">
Motion Mask {polygon.name.length ? "Edit" : "New"} Motion Mask
</Heading> </Heading>
<div className="text-sm text-muted-foreground my-2">
<p>
Motion masks are used to prevent unwanted types of motion from
triggering detection. Over masking will make it more difficult for
objects to be tracked.
</p>
</div>
<Separator className="my-3 bg-secondary" /> <Separator className="my-3 bg-secondary" />
{polygons && activePolygonIndex !== undefined && ( {polygons && activePolygonIndex !== undefined && (
<div className="flex flex-row my-2 text-sm w-full justify-between"> <div className="flex flex-row my-2 text-sm w-full justify-between">
<div className="my-1"> <div className="my-1 inline-flex">
{polygons[activePolygonIndex].points.length} points {polygons[activePolygonIndex].points.length}{" "}
{polygons[activePolygonIndex].points.length > 1 ||
polygons[activePolygonIndex].points.length == 0
? "points"
: "point"}
{polygons[activePolygonIndex].isFinished && (
<FaCheckCircle className="ml-2 size-5" />
)}
</div> </div>
{polygons[activePolygonIndex].isFinished ? <></> : <></>}
<PolygonEditControls <PolygonEditControls
polygons={polygons} polygons={polygons}
setPolygons={setPolygons} setPolygons={setPolygons}
@ -101,6 +123,15 @@ export default function MotionMaskEditPane({
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="polygon.name"
render={() => (
<FormItem>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="polygon.isFinished" name="polygon.isFinished"

View File

@ -19,15 +19,15 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { useEffect, useMemo, useState } from "react"; import { useMemo } from "react";
import { ATTRIBUTE_LABELS, FrigateConfig } from "@/types/frigateConfig"; import { ATTRIBUTE_LABELS, FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr"; import useSWR from "swr";
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 { Polygon } from "@/types/canvas";
import PolygonEditControls from "./PolygonEditControls"; import PolygonEditControls from "./PolygonEditControls";
import { FaCheckCircle } from "react-icons/fa";
type ObjectMaskEditPaneProps = { type ObjectMaskEditPaneProps = {
polygons?: Polygon[]; polygons?: Polygon[];
@ -44,17 +44,17 @@ export default function ObjectMaskEditPane({
onSave, onSave,
onCancel, onCancel,
}: ObjectMaskEditPaneProps) { }: ObjectMaskEditPaneProps) {
const { data: config } = useSWR<FrigateConfig>("config"); // const { data: config } = useSWR<FrigateConfig>("config");
const cameras = useMemo(() => { // const cameras = useMemo(() => {
if (!config) { // if (!config) {
return []; // return [];
} // }
return Object.values(config.cameras) // return Object.values(config.cameras)
.filter((conf) => conf.ui.dashboard && conf.enabled) // .filter((conf) => conf.ui.dashboard && conf.enabled)
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); // .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]); // }, [config]);
const polygon = useMemo(() => { const polygon = useMemo(() => {
if (polygons && activePolygonIndex !== undefined) { if (polygons && activePolygonIndex !== undefined) {
@ -64,10 +64,28 @@ export default function ObjectMaskEditPane({
} }
}, [polygons, activePolygonIndex]); }, [polygons, activePolygonIndex]);
const defaultName = useMemo(() => {
if (!polygons) {
return;
}
const count = polygons.filter((poly) => poly.type == "object_mask").length;
let objectType = "";
const objects = polygon?.objects[0];
if (objects === undefined) {
objectType = "all objects";
} else {
objectType = objects;
}
return `Object Mask ${count + 1} (${objectType})`;
}, [polygons, polygon]);
const formSchema = z const formSchema = z
.object({ .object({
objects: z.string(), objects: z.string(),
polygon: z.object({ isFinished: z.boolean() }), polygon: z.object({ isFinished: z.boolean(), name: z.string() }),
}) })
.refine(() => polygon?.isFinished === true, { .refine(() => polygon?.isFinished === true, {
message: "The polygon drawing must be finished before saving.", message: "The polygon drawing must be finished before saving.",
@ -79,17 +97,28 @@ export default function ObjectMaskEditPane({
mode: "onChange", mode: "onChange",
defaultValues: { defaultValues: {
objects: polygon?.objects[0] ?? "all_labels", objects: polygon?.objects[0] ?? "all_labels",
polygon: { isFinished: polygon?.isFinished ?? false }, polygon: { isFinished: polygon?.isFinished ?? false, name: defaultName },
}, },
}); });
function onSubmit(values: z.infer<typeof formSchema>) { function onSubmit(values: z.infer<typeof formSchema>) {
// polygons[activePolygonIndex].name = values.name;
console.log("form values", values); console.log("form values", values);
console.log("active polygon", polygons[activePolygonIndex]); // if (activePolygonIndex === undefined || !polygons) {
// make sure polygon isFinished // return;
// }
// const updatedPolygons = [...polygons];
// const activePolygon = updatedPolygons[activePolygonIndex];
// updatedPolygons[activePolygonIndex] = {
// ...activePolygon,
// name: defaultName ?? "foo",
// };
// setPolygons(updatedPolygons);
if (onSave) {
onSave(); onSave();
} }
}
if (!polygon) { if (!polygon) {
return; return;
@ -98,15 +127,27 @@ export default function ObjectMaskEditPane({
return ( return (
<> <>
<Heading as="h3" className="my-2"> <Heading as="h3" className="my-2">
Object Mask {polygon.name.length ? "Edit" : "New"} Object Mask
</Heading> </Heading>
<div className="text-sm text-muted-foreground my-2">
<p>
Object filter masks are used to filter out false positives for a given
object type based on location.
</p>
</div>
<Separator className="my-3 bg-secondary" /> <Separator className="my-3 bg-secondary" />
{polygons && activePolygonIndex !== undefined && ( {polygons && activePolygonIndex !== undefined && (
<div className="flex flex-row my-2 text-sm w-full justify-between"> <div className="flex flex-row my-2 text-sm w-full justify-between">
<div className="my-1"> <div className="my-1 inline-flex">
{polygons[activePolygonIndex].points.length} points {polygons[activePolygonIndex].points.length}{" "}
{polygons[activePolygonIndex].points.length > 1 ||
polygons[activePolygonIndex].points.length == 0
? "points"
: "point"}
{polygons[activePolygonIndex].isFinished && (
<FaCheckCircle className="ml-2 size-5" />
)}
</div> </div>
{polygons[activePolygonIndex].isFinished ? <></> : <></>}
<PolygonEditControls <PolygonEditControls
polygons={polygons} polygons={polygons}
setPolygons={setPolygons} setPolygons={setPolygons}
@ -122,6 +163,15 @@ export default function ObjectMaskEditPane({
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="polygon.name"
render={() => (
<FormItem>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="objects" name="objects"
@ -138,12 +188,7 @@ export default function ObjectMaskEditPane({
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<ZoneObjectSelector <ZoneObjectSelector camera={polygon.camera} />
camera={polygon.camera}
updateLabelFilter={(objects) => {
// console.log(objects);
}}
/>
</SelectContent> </SelectContent>
</Select> </Select>
<FormDescription> <FormDescription>
@ -178,13 +223,9 @@ export default function ObjectMaskEditPane({
type ZoneObjectSelectorProps = { type ZoneObjectSelectorProps = {
camera: string; camera: string;
updateLabelFilter: (labels: string[] | undefined) => void;
}; };
export function ZoneObjectSelector({ export function ZoneObjectSelector({ camera }: ZoneObjectSelectorProps) {
camera,
updateLabelFilter,
}: ZoneObjectSelectorProps) {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const cameraConfig = useMemo(() => { const cameraConfig = useMemo(() => {
@ -194,7 +235,7 @@ export function ZoneObjectSelector({
}, [config, camera]); }, [config, camera]);
const allLabels = useMemo<string[]>(() => { const allLabels = useMemo<string[]>(() => {
if (!config) { if (!config || !cameraConfig) {
return []; return [];
} }
@ -208,34 +249,14 @@ export function ZoneObjectSelector({
}); });
}); });
return [...labels].sort();
}, [config]);
const cameraLabels = useMemo<string[]>(() => {
if (!cameraConfig) {
return [];
}
const labels = new Set<string>();
cameraConfig.objects.track.forEach((label) => { cameraConfig.objects.track.forEach((label) => {
if (!ATTRIBUTE_LABELS.includes(label)) { if (!ATTRIBUTE_LABELS.includes(label)) {
labels.add(label); labels.add(label);
} }
}); });
return [...labels].sort() || []; return [...labels].sort();
}, [cameraConfig]); }, [config, cameraConfig]);
const [currentLabels, setCurrentLabels] = useState<string[] | undefined>(
cameraLabels.every((label, index) => label === allLabels[index])
? undefined
: cameraLabels,
);
useEffect(() => {
updateLabelFilter(currentLabels);
}, [currentLabels, updateLabelFilter]);
return ( return (
<> <>

View File

@ -5,7 +5,7 @@ import Konva from "konva";
import type { KonvaEventObject } from "konva/lib/Node"; import type { KonvaEventObject } from "konva/lib/Node";
import { Polygon, PolygonType } from "@/types/canvas"; import { Polygon, PolygonType } from "@/types/canvas";
import { useApiHost } from "@/api"; import { useApiHost } from "@/api";
import { getAveragePoint } from "@/utils/canvasUtil"; import { getAveragePoint, flattenPoints } from "@/utils/canvasUtil";
type PolygonCanvasProps = { type PolygonCanvasProps = {
camera: string; camera: string;
@ -165,10 +165,6 @@ export function PolygonCanvas({
} }
}; };
const flattenPoints = (points: number[][]): number[] => {
return points.reduce((acc, point) => [...acc, ...point], []);
};
const handleGroupDragEnd = (e: KonvaEventObject<MouseEvent | TouchEvent>) => { const handleGroupDragEnd = (e: KonvaEventObject<MouseEvent | TouchEvent>) => {
if (activePolygonIndex !== undefined && e.target.name() === "polygon") { if (activePolygonIndex !== undefined && e.target.name() === "polygon") {
const updatedPolygons = [...polygons]; const updatedPolygons = [...polygons];

View File

@ -22,7 +22,13 @@ 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 { toRGBColorString } from "@/utils/canvasUtil";
import { Polygon, PolygonType } from "@/types/canvas"; import { Polygon, PolygonType } from "@/types/canvas";
import { useState } from "react"; import { useCallback, useMemo, useState } from "react";
import axios from "axios";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { reviewQueries } from "@/utils/zoneEdutUtil";
type PolygonItemProps = { type PolygonItemProps = {
polygon: Polygon; polygon: Polygon;
@ -47,8 +53,16 @@ export default function PolygonItem({
setEditPane, setEditPane,
handleCopyCoordinates, handleCopyCoordinates,
}: PolygonItemProps) { }: PolygonItemProps) {
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const cameraConfig = useMemo(() => {
if (polygon?.camera && config) {
return config.cameras[polygon.camera];
}
}, [polygon, config]);
const polygonTypeIcons = { const polygonTypeIcons = {
zone: FaDrawPolygon, zone: FaDrawPolygon,
motion_mask: FaObjectGroup, motion_mask: FaObjectGroup,
@ -57,14 +71,66 @@ export default function PolygonItem({
const PolygonItemIcon = polygon ? polygonTypeIcons[polygon.type] : undefined; const PolygonItemIcon = polygon ? polygonTypeIcons[polygon.type] : undefined;
const saveToConfig = useCallback(
async (polygon: Polygon) => {
if (!polygon || !cameraConfig) {
return;
}
let url = "";
if (polygon.type == "zone") {
const { alertQueries, detectionQueries } = reviewQueries(
polygon.name,
false,
false,
polygon.camera,
cameraConfig?.review.alerts.required_zones || [],
cameraConfig?.review.detections.required_zones || [],
);
url = `config/set?cameras.${polygon.camera}.zones.${polygon.name}${alertQueries}${detectionQueries}`;
}
if (polygon.type == "motion_mask") {
url = `config/set?cameras.${polygon.camera}.motion.mask`;
}
axios
.put(url, { requires_restart: 0 })
.then((res) => {
if (res.status === 200) {
toast.success(`${polygon?.name} has been deleted.`, {
position: "top-center",
});
// setChangedValue(false);
updateConfig();
} else {
toast.error(`Failed to save config changes: ${res.statusText}`, {
position: "top-center",
});
}
})
.catch((error) => {
toast.error(
`Failed to save config changes: ${error.response.data.message}`,
{ position: "top-center" },
);
})
.finally(() => {
// setIsLoading(false);
});
},
[updateConfig],
);
const handleDelete = (index: number) => { const handleDelete = (index: number) => {
setAllPolygons((oldPolygons) => { setAllPolygons((oldPolygons) => {
return oldPolygons.filter((_, i) => i !== index); return oldPolygons.filter((_, i) => i !== index);
}); });
setActivePolygonIndex(undefined); setActivePolygonIndex(undefined);
saveToConfig(polygon);
}; };
return ( return (
<>
<Toaster position="top-center" />
<div <div
key={index} key={index}
className="flex p-1 rounded-lg flex-row items-center justify-between mx-2 my-1.5 transition-background duration-100" className="flex p-1 rounded-lg flex-row items-center justify-between mx-2 my-1.5 transition-background duration-100"
@ -105,8 +171,8 @@ export default function PolygonItem({
<AlertDialogTitle>Confirm Delete</AlertDialogTitle> <AlertDialogTitle>Confirm Delete</AlertDialogTitle>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogDescription> <AlertDialogDescription>
Are you sure you want to delete the {polygon.type.replace("_", " ")}{" "} Are you sure you want to delete the{" "}
<em>{polygon.name}</em>? {polygon.type.replace("_", " ")} <em>{polygon.name}</em>?
</AlertDialogDescription> </AlertDialogDescription>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
@ -196,5 +262,6 @@ export default function PolygonItem({
</div> </div>
)} )}
</div> </div>
</>
); );
} }

View File

@ -11,7 +11,7 @@ import {
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { ATTRIBUTE_LABELS, FrigateConfig } from "@/types/frigateConfig"; import { ATTRIBUTE_LABELS, FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr"; import useSWR from "swr";
import { isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
@ -19,14 +19,25 @@ 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 { Polygon } from "@/types/canvas";
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";
import PolygonEditControls from "./PolygonEditControls"; import PolygonEditControls from "./PolygonEditControls";
import { FaCheckCircle } from "react-icons/fa";
import axios from "axios";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import { flattenPoints, interpolatePoints } from "@/utils/canvasUtil";
import ActivityIndicator from "../indicators/activity-indicator";
type ZoneEditPaneProps = { type ZoneEditPaneProps = {
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;
}; };
@ -35,10 +46,15 @@ export default function ZoneEditPane({
polygons, polygons,
setPolygons, setPolygons,
activePolygonIndex, activePolygonIndex,
scaledWidth,
scaledHeight,
isLoading,
setIsLoading,
onSave, onSave,
onCancel, onCancel,
}: ZoneEditPaneProps) { }: ZoneEditPaneProps) {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const cameras = useMemo(() => { const cameras = useMemo(() => {
if (!config) { if (!config) {
@ -58,8 +74,13 @@ export default function ZoneEditPane({
} }
}, [polygons, activePolygonIndex]); }, [polygons, activePolygonIndex]);
const formSchema = z const cameraConfig = useMemo(() => {
.object({ if (polygon?.camera && config) {
return config.cameras[polygon.camera];
}
}, [polygon, config]);
const formSchema = z.object({
name: z name: z
.string() .string()
.min(2, { .min(2, {
@ -93,11 +114,12 @@ export default function ZoneEditPane({
loitering_time: z.coerce.number().min(0, { loitering_time: z.coerce.number().min(0, {
message: "Loitering time must be greater than or equal to 0.", message: "Loitering time must be greater than or equal to 0.",
}), }),
polygon: z.object({ isFinished: z.boolean() }), isFinished: z.boolean().refine(() => polygon?.isFinished === true, {
})
.refine(() => polygon?.isFinished === true, {
message: "The polygon drawing must be finished before saving.", message: "The polygon drawing must be finished before saving.",
path: ["polygon.isFinished"], }),
objects: z.array(z.string()).optional(),
review_alerts: z.boolean().default(false).optional(),
review_detections: z.boolean().default(false).optional(),
}); });
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
@ -106,26 +128,238 @@ export default function ZoneEditPane({
defaultValues: { defaultValues: {
name: polygon?.name ?? "", name: polygon?.name ?? "",
inertia: inertia:
((polygon?.camera && (polygon?.camera &&
polygon?.name && polygon?.name &&
config?.cameras[polygon.camera]?.zones[polygon.name] config?.cameras[polygon.camera]?.zones[polygon.name]?.inertia) ||
?.inertia) as number) || 3, 3,
loitering_time: loitering_time:
((polygon?.camera && (polygon?.camera &&
polygon?.name && polygon?.name &&
config?.cameras[polygon.camera]?.zones[polygon.name] config?.cameras[polygon.camera]?.zones[polygon.name]
?.loitering_time) as number) || 0, ?.loitering_time) ||
polygon: { isFinished: polygon?.isFinished ?? false }, 0,
isFinished: polygon?.isFinished ?? false,
objects: polygon?.objects ?? [],
review_alerts:
(polygon?.camera &&
polygon?.name &&
config?.cameras[
polygon.camera
]?.review.alerts.required_zones.includes(polygon.name)) ||
false,
review_detections:
(polygon?.camera &&
polygon?.name &&
config?.cameras[
polygon.camera
]?.review.detections.required_zones.includes(polygon.name)) ||
false,
}, },
}); });
// const [changedValue, setChangedValue] = useState(false);
type FormValuesType = {
name: string;
inertia: number;
loitering_time: number;
isFinished: boolean;
objects: string[];
review_alerts: boolean;
review_detections: boolean;
};
// const requiredDetectionZones = useMemo(
// () => cameraConfig?.review.detections.required_zones,
// [cameraConfig],
// );
// const requiredAlertZones = useMemo(
// () => cameraConfig?.review.alerts.required_zones,
// [cameraConfig],
// );
// const [alertQueries, setAlertQueries] = useState("");
// const [detectionQueries, setDetectionQueries] = useState("");
// useEffect(() => {
// console.log("config updated!", config);
// }, [config]);
// useEffect(() => {
// console.log("camera config updated!", cameraConfig);
// }, [cameraConfig]);
// useEffect(() => {
// console.log("required zones updated!", requiredZones);
// }, [requiredZones]);
const saveToConfig = useCallback(
async (
{
name,
inertia,
loitering_time,
objects: form_objects,
review_alerts,
review_detections,
}: FormValuesType, // values submitted via the form
objects: string[],
) => {
if (!scaledWidth || !scaledHeight || !polygon) {
return;
}
// console.log("loitering time", loitering_time);
// const alertsZones = config?.cameras[camera]?.review.alerts.required_zones;
// const detectionsZones =
// config?.cameras[camera]?.review.detections.required_zones;
let mutatedConfig = config;
const renamingZone = name != polygon.name && polygon.name != "";
if (renamingZone) {
// rename - delete old zone and replace with new
const {
alertQueries: renameAlertQueries,
detectionQueries: renameDetectionQueries,
} = reviewQueries(
polygon.name,
false,
false,
polygon.camera,
cameraConfig?.review.alerts.required_zones || [],
cameraConfig?.review.detections.required_zones || [],
);
try {
await axios.put(
`config/set?cameras.${polygon.camera}.zones.${polygon.name}${renameAlertQueries}${renameDetectionQueries}`,
{
requires_restart: 0,
},
);
// Wait for the config to be updated
mutatedConfig = await updateConfig();
// console.log("this should be updated...", mutatedConfig.cameras);
// console.log("check original config object...", config);
} catch (error) {
toast.error(`Failed to save config changes.`, {
position: "top-center",
});
return;
}
}
// console.log("out of try except", mutatedConfig);
const coordinates = flattenPoints(
interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1),
).join(",");
// const foo = config.cameras["doorbell"].zones["outside"].objects;
let objectQueries = objects
.map(
(object) =>
`&cameras.${polygon?.camera}.zones.${name}.objects=${object}`,
)
.join("");
const same_objects =
form_objects.length == objects.length &&
form_objects.every(function (element, index) {
return element === objects[index];
});
// deleting objects
if (!objectQueries && !same_objects && !renamingZone) {
// console.log("deleting objects");
objectQueries = `&cameras.${polygon?.camera}.zones.${name}.objects`;
}
const { alertQueries, detectionQueries } = reviewQueries(
name,
review_alerts,
review_detections,
polygon.camera,
mutatedConfig?.cameras[polygon.camera]?.review.alerts.required_zones ||
[],
mutatedConfig?.cameras[polygon.camera]?.review.detections
.required_zones || [],
);
// console.log("object queries:", objectQueries);
// console.log("alert queries:", alertQueries);
// console.log("detection queries:", detectionQueries);
// console.log(
// `config/set?cameras.${polygon?.camera}.zones.${name}.coordinates=${coordinates}&cameras.${polygon?.camera}.zones.${name}.inertia=${inertia}&cameras.${polygon?.camera}.zones.${name}.loitering_time=${loitering_time}${objectQueries}${alertQueries}${detectionQueries}`,
// );
axios
.put(
`config/set?cameras.${polygon?.camera}.zones.${name}.coordinates=${coordinates}&cameras.${polygon?.camera}.zones.${name}.inertia=${inertia}&cameras.${polygon?.camera}.zones.${name}.loitering_time=${loitering_time}${objectQueries}${alertQueries}${detectionQueries}`,
{ requires_restart: 0 },
)
.then((res) => {
if (res.status === 200) {
toast.success(`Zone ${name} saved.`, {
position: "top-center",
});
// setChangedValue(false);
updateConfig();
} else {
toast.error(`Failed to save config changes: ${res.statusText}`, {
position: "top-center",
});
}
})
.catch((error) => {
toast.error(
`Failed to save config changes: ${error.response.data.message}`,
{ position: "top-center" },
);
})
.finally(() => {
setIsLoading(false);
});
},
[
config,
updateConfig,
polygon,
scaledWidth,
scaledHeight,
setIsLoading,
cameraConfig,
],
);
function onSubmit(values: z.infer<typeof formSchema>) { function onSubmit(values: z.infer<typeof formSchema>) {
polygons[activePolygonIndex].name = values.name; if (activePolygonIndex === undefined || !values || !polygons) {
console.log("form values", values); return;
console.log("active polygon", polygons[activePolygonIndex]); }
// make sure polygon isFinished setIsLoading(true);
// polygons[activePolygonIndex].name = values.name;
// console.log("form values", values);
// console.log(
// "string",
// flattenPoints(
// interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1),
// ).join(","),
// );
// console.log("active polygon", polygons[activePolygonIndex]);
saveToConfig(
values as FormValuesType,
polygons[activePolygonIndex].objects,
);
if (onSave) {
onSave(); onSave();
} }
}
if (!polygon) { if (!polygon) {
return; return;
@ -133,16 +367,29 @@ export default function ZoneEditPane({
return ( return (
<> <>
<Toaster position="top-center" />
<Heading as="h3" className="my-2"> <Heading as="h3" className="my-2">
Zone {polygon.name.length ? "Edit" : "New"} Zone
</Heading> </Heading>
<div className="text-sm text-muted-foreground my-2">
<p>
Zones allow you to define a specific area of the frame so you can
determine whether or not an object is within a particular area.
</p>
</div>
<Separator className="my-3 bg-secondary" /> <Separator className="my-3 bg-secondary" />
{polygons && activePolygonIndex !== undefined && ( {polygons && activePolygonIndex !== undefined && (
<div className="flex flex-row my-2 text-sm w-full justify-between"> <div className="flex flex-row my-2 text-sm w-full justify-between">
<div className="my-1"> <div className="my-1 inline-flex">
{polygons[activePolygonIndex].points.length} points {polygons[activePolygonIndex].points.length}{" "}
{polygons[activePolygonIndex].points.length > 1 ||
polygons[activePolygonIndex].points.length == 0
? "points"
: "point"}
{polygons[activePolygonIndex].isFinished && (
<FaCheckCircle className="ml-2 size-5" />
)}
</div> </div>
{polygons[activePolygonIndex].isFinished ? <></> : <></>}
<PolygonEditControls <PolygonEditControls
polygons={polygons} polygons={polygons}
setPolygons={setPolygons} setPolygons={setPolygons}
@ -197,7 +444,7 @@ export default function ZoneEditPane({
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Specifies how many frames that an object must be in a zone Specifies how many frames that an object must be in a zone
before they are considered in the zone. before they are considered in the zone. <em>Default: 3</em>
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -221,7 +468,7 @@ export default function ZoneEditPane({
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Sets a minimum amount of time in seconds that the object must Sets a minimum amount of time in seconds that the object must
be in the zone for it to activate. be in the zone for it to activate. <em>Default: 0</em>
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -238,64 +485,69 @@ export default function ZoneEditPane({
<ZoneObjectSelector <ZoneObjectSelector
camera={polygon.camera} camera={polygon.camera}
zoneName={polygon.name} zoneName={polygon.name}
selectedLabels={polygon.objects}
updateLabelFilter={(objects) => { updateLabelFilter={(objects) => {
// console.log(objects); if (activePolygonIndex === undefined || !polygons) {
return;
}
const updatedPolygons = [...polygons];
const activePolygon = updatedPolygons[activePolygonIndex];
updatedPolygons[activePolygonIndex] = {
...activePolygon,
objects: objects ?? [],
};
setPolygons(updatedPolygons);
}} }}
/> />
</FormItem> </FormItem>
<div className="flex my-2"> <div className="flex my-2">
<Separator className="bg-secondary" /> <Separator className="bg-secondary" />
</div> </div>
<FormItem>
<FormLabel>Alerts and Detections</FormLabel>
<FormDescription>
When an object enters this zone, ensure it is marked as an alert
or detection.
</FormDescription>
<FormControl>
<div className="flex flex-col gap-2.5">
<div className="flex flex-row justify-between items-center">
<Label
className="text-primary cursor-pointer"
htmlFor="mark_alert"
>
Required for Alert
</Label>
<Switch
className="ml-1"
id="mark_alert"
checked={false}
onCheckedChange={(isChecked) => {
if (isChecked) {
return;
}
}}
/>
</div>
<div className="flex flex-row justify-between items-center">
<Label
className="text-primary cursor-pointer"
htmlFor="mark_detection"
>
Required for Detection
</Label>
<Switch
className="ml-1"
id="mark_detection"
checked={false}
onCheckedChange={(isChecked) => {
if (isChecked) {
return;
}
}}
/>
</div>
</div>
</FormControl>
</FormItem>
<FormField <FormField
control={form.control} control={form.control}
name="polygon.isFinished" name="review_alerts"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>Alerts</FormLabel>
<FormDescription>
When an object enters this zone, ensure it is marked as an
alert.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="review_detections"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Detections</FormLabel>
<FormDescription>
When an object enters this zone, ensure it is marked as a
detection.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="isFinished"
render={() => ( render={() => (
<FormItem> <FormItem>
<FormMessage /> <FormMessage />
@ -306,8 +558,20 @@ export default function ZoneEditPane({
<Button className="flex flex-1" onClick={onCancel}> <Button className="flex flex-1" onClick={onCancel}>
Cancel Cancel
</Button> </Button>
<Button variant="select" className="flex flex-1" type="submit"> <Button
Save variant="select"
disabled={isLoading}
className="flex flex-1"
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>Saving...</span>
</div>
) : (
"Save"
)}
</Button> </Button>
</div> </div>
</form> </form>
@ -319,12 +583,14 @@ export default function ZoneEditPane({
type ZoneObjectSelectorProps = { type ZoneObjectSelectorProps = {
camera: string; camera: string;
zoneName: string; zoneName: string;
selectedLabels: string[];
updateLabelFilter: (labels: string[] | undefined) => void; updateLabelFilter: (labels: string[] | undefined) => void;
}; };
export function ZoneObjectSelector({ export function ZoneObjectSelector({
camera, camera,
zoneName, zoneName,
selectedLabels,
updateLabelFilter, updateLabelFilter,
}: ZoneObjectSelectorProps) { }: ZoneObjectSelectorProps) {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
@ -336,7 +602,7 @@ export function ZoneObjectSelector({
}, [config, camera]); }, [config, camera]);
const allLabels = useMemo<string[]>(() => { const allLabels = useMemo<string[]>(() => {
if (!config) { if (!cameraConfig || !config) {
return []; return [];
} }
@ -350,22 +616,13 @@ export function ZoneObjectSelector({
}); });
}); });
return [...labels].sort();
}, [config]);
const zoneLabels = useMemo<string[]>(() => {
if (!cameraConfig || !zoneName) {
return [];
}
const labels = new Set<string>();
cameraConfig.objects.track.forEach((label) => { cameraConfig.objects.track.forEach((label) => {
if (!ATTRIBUTE_LABELS.includes(label)) { if (!ATTRIBUTE_LABELS.includes(label)) {
labels.add(label); labels.add(label);
} }
}); });
if (zoneName) {
if (cameraConfig.zones[zoneName]) { if (cameraConfig.zones[zoneName]) {
cameraConfig.zones[zoneName].objects.forEach((label) => { cameraConfig.zones[zoneName].objects.forEach((label) => {
if (!ATTRIBUTE_LABELS.includes(label)) { if (!ATTRIBUTE_LABELS.includes(label)) {
@ -373,14 +630,13 @@ export function ZoneObjectSelector({
} }
}); });
} }
}
return [...labels].sort() || []; return [...labels].sort() || [];
}, [cameraConfig, zoneName]); }, [config, cameraConfig, zoneName]);
const [currentLabels, setCurrentLabels] = useState<string[] | undefined>( const [currentLabels, setCurrentLabels] = useState<string[] | undefined>(
zoneLabels.every((label, index) => label === allLabels[index]) selectedLabels,
? undefined
: zoneLabels,
); );
useEffect(() => { useEffect(() => {
@ -397,10 +653,10 @@ export function ZoneObjectSelector({
<Switch <Switch
className="ml-1" className="ml-1"
id="allLabels" id="allLabels"
checked={currentLabels == undefined} checked={!currentLabels?.length}
onCheckedChange={(isChecked) => { onCheckedChange={(isChecked) => {
if (isChecked) { if (isChecked) {
setCurrentLabels(undefined); setCurrentLabels([]);
} }
}} }}
/> />

View File

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

View File

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

View File

@ -64,6 +64,10 @@ export const interpolatePoints = (
return newPoints; return newPoints;
}; };
export const flattenPoints = (points: number[][]): number[] => {
return points.reduce((acc, point) => [...acc, ...point], []);
};
export const toRGBColorString = (color: number[], darkened: boolean) => { export const toRGBColorString = (color: number[], darkened: boolean) => {
if (color.length !== 3) { if (color.length !== 3) {
return "rgb(220,0,0,0.5)"; return "rgb(220,0,0,0.5)";

View File

@ -0,0 +1,94 @@
export const reviewQueries = (
name: string,
review_alerts: boolean,
review_detections: boolean,
camera: string,
alertsZones: string[],
detectionsZones: string[],
) => {
let alertQueries = "";
let detectionQueries = "";
let same_alerts = false;
let same_detections = false;
// const foo = config;
// console.log("config in func", config.cameras);
// console.log("config as foo in func", foo.cameras);
// console.log("cameraconfig in func", cameraConfig);
// console.log("required zones in func", requiredZones);
// console.log("name", name);
// console.log("alerts", alertsZones);
// console.log("detections", detectionsZones);
// console.log(
// "orig detections",
// foo?.cameras[camera]?.review.detections.required_zones,
// );
const alerts = new Set<string>(alertsZones || []);
// config?.cameras[camera].review.alerts.required_zones.forEach((zone) => {
// alerts.add(zone);
// });
if (review_alerts) {
alerts.add(name);
} else {
same_alerts = !alerts.has(name);
alerts.delete(name);
}
alertQueries = [...alerts]
.map((zone) => `&cameras.${camera}.review.alerts.required_zones=${zone}`)
.join("");
const detections = new Set<string>(detectionsZones || []);
// config?.cameras[camera].review.detections.required_zones.forEach((zone) => {
// detections.add(zone);
// });
if (review_detections) {
detections.add(name);
} else {
same_detections = !detections.has(name);
detections.delete(name);
}
detectionQueries = [...detections]
.map(
(zone) => `&cameras.${camera}.review.detections.required_zones=${zone}`,
)
.join("");
// console.log("dets set", detections);
// const updatedConfig = updateConfig({
// ...config,
// cameras: {
// ...config.cameras,
// [camera]: {
// ...config.cameras[camera],
// review: {
// ...config.cameras[camera].review,
// detection: {
// ...config.cameras[camera].review.detection,
// required_zones: [...detections],
// },
// },
// },
// },
// });
// console.log(updatedConfig);
// console.log("alert queries", alertQueries);
// console.log("detection queries", detectionQueries);
if (!alertQueries && !same_alerts) {
// console.log("deleting alerts");
alertQueries = `&cameras.${camera}.review.alerts`;
}
if (!detectionQueries && !same_detections) {
// console.log("deleting detection");
detectionQueries = `&cameras.${camera}.review.detections`;
}
return { alertQueries, detectionQueries };
};