Estimated object speed for zones (#16452)

* utility functions

* backend config

* backend object speed tracking

* draw speed on debug view

* basic frontend zone editor

* remove line sorting

* fix types

* highlight line on canvas when entering value in zone edit pane

* rename vars and add validation

* ensure speed estimation is disabled when user adds more than 4 points

* pixel velocity in debug

* unit_system in config

* ability to define unit system in config

* save max speed to db

* frontend

* docs

* clarify docs

* utility functions

* backend config

* backend object speed tracking

* draw speed on debug view

* basic frontend zone editor

* remove line sorting

* fix types

* highlight line on canvas when entering value in zone edit pane

* rename vars and add validation

* ensure speed estimation is disabled when user adds more than 4 points

* pixel velocity in debug

* unit_system in config

* ability to define unit system in config

* save max speed to db

* frontend

* docs

* clarify docs

* fix duplicates from merge

* include max_estimated_speed in api responses

* add units to zone edit pane

* catch undefined

* add average speed

* clarify docs

* only track average speed when object is active

* rename vars

* ensure points and distances are ordered clockwise

* only store the last 10 speeds like score history

* remove max estimated speed

* update docs

* update docs

* fix point ordering

* improve readability

* docs inertia recommendation

* fix point ordering

* check object frame time

* add velocity angle to frontend

* docs clarity

* add frontend speed filter

* fix mqtt docs

* fix mqtt docs

* don't try to remove distances if they weren't already defined

* don't display estimates on debug view/snapshots if object is not in a speed tracking zone

* docs

* implement speed_threshold for zone presence

* docs for threshold

* better ground plane image

* improve image zone size

* add inertia to speed threshold example
This commit is contained in:
Josh Hawkins
2025-02-10 13:23:42 -07:00
committed by GitHub
parent dd7820e4ee
commit 72209986b6
25 changed files with 1030 additions and 79 deletions
@@ -17,6 +17,7 @@ type PolygonCanvasProps = {
activePolygonIndex: number | undefined;
hoveredPolygonIndex: number | null;
selectedZoneMask: PolygonType[] | undefined;
activeLine?: number;
};
export function PolygonCanvas({
@@ -29,6 +30,7 @@ export function PolygonCanvas({
activePolygonIndex,
hoveredPolygonIndex,
selectedZoneMask,
activeLine,
}: PolygonCanvasProps) {
const [isLoaded, setIsLoaded] = useState(false);
const [image, setImage] = useState<HTMLImageElement | undefined>();
@@ -281,12 +283,14 @@ export function PolygonCanvas({
stageRef={stageRef}
key={index}
points={polygon.points}
distances={polygon.distances}
isActive={index === activePolygonIndex}
isHovered={index === hoveredPolygonIndex}
isFinished={polygon.isFinished}
color={polygon.color}
handlePointDragMove={handlePointDragMove}
handleGroupDragEnd={handleGroupDragEnd}
activeLine={activeLine}
/>
),
)}
@@ -298,12 +302,14 @@ export function PolygonCanvas({
stageRef={stageRef}
key={activePolygonIndex}
points={polygons[activePolygonIndex].points}
distances={polygons[activePolygonIndex].distances}
isActive={true}
isHovered={activePolygonIndex === hoveredPolygonIndex}
isFinished={polygons[activePolygonIndex].isFinished}
color={polygons[activePolygonIndex].color}
handlePointDragMove={handlePointDragMove}
handleGroupDragEnd={handleGroupDragEnd}
activeLine={activeLine}
/>
)}
</Layer>
+77 -1
View File
@@ -6,7 +6,7 @@ import {
useRef,
useState,
} from "react";
import { Line, Circle, Group } from "react-konva";
import { Line, Circle, Group, Text, Rect } from "react-konva";
import {
minMax,
toRGBColorString,
@@ -20,23 +20,27 @@ import { Vector2d } from "konva/lib/types";
type PolygonDrawerProps = {
stageRef: RefObject<Konva.Stage>;
points: number[][];
distances: number[];
isActive: boolean;
isHovered: boolean;
isFinished: boolean;
color: number[];
handlePointDragMove: (e: KonvaEventObject<MouseEvent | TouchEvent>) => void;
handleGroupDragEnd: (e: KonvaEventObject<MouseEvent | TouchEvent>) => void;
activeLine?: number;
};
export default function PolygonDrawer({
stageRef,
points,
distances,
isActive,
isHovered,
isFinished,
color,
handlePointDragMove,
handleGroupDragEnd,
activeLine,
}: PolygonDrawerProps) {
const vertexRadius = 6;
const flattenedPoints = useMemo(() => flattenPoints(points), [points]);
@@ -113,6 +117,33 @@ export default function PolygonDrawer({
stageRef.current.container().style.cursor = cursor;
}, [stageRef, cursor]);
// Calculate midpoints for distance labels based on sorted points
const midpoints = useMemo(() => {
const midpointsArray = [];
for (let i = 0; i < points.length; i++) {
const p1 = points[i];
const p2 = points[(i + 1) % points.length];
const midpointX = (p1[0] + p2[0]) / 2;
const midpointY = (p1[1] + p2[1]) / 2;
midpointsArray.push([midpointX, midpointY]);
}
return midpointsArray;
}, [points]);
// Determine the points for the active line
const activeLinePoints = useMemo(() => {
if (
activeLine === undefined ||
activeLine < 1 ||
activeLine > points.length
) {
return [];
}
const p1 = points[activeLine - 1];
const p2 = points[activeLine % points.length];
return [p1[0], p1[1], p2[0], p2[1]];
}, [activeLine, points]);
return (
<Group
name="polygon"
@@ -158,6 +189,14 @@ export default function PolygonDrawer({
}
/>
)}
{isActive && activeLinePoints.length > 0 && (
<Line
points={activeLinePoints}
stroke="white"
strokeWidth={6}
hitStrokeWidth={12}
/>
)}
{points.map((point, index) => {
if (!isActive) {
return;
@@ -195,6 +234,43 @@ export default function PolygonDrawer({
/>
);
})}
{isFinished && (
<Group>
{midpoints.map((midpoint, index) => {
const [x, y] = midpoint;
const distance = distances[index];
if (distance === undefined) return null;
const squareSize = 22;
return (
<Group
key={`distance-group-${index}`}
x={x - squareSize / 2}
y={y - squareSize / 2}
>
<Rect
width={squareSize}
height={squareSize}
fill={colorString(true)}
stroke="white"
strokeWidth={1}
/>
<Text
text={`${distance}`}
width={squareSize}
y={4}
fontSize={16}
fontFamily="Arial"
fill="white"
align="center"
verticalAlign="middle"
/>
</Group>
);
})}
</Group>
)}
</Group>
);
}
+367 -60
View File
@@ -40,6 +40,7 @@ type ZoneEditPaneProps = {
setIsLoading: React.Dispatch<React.SetStateAction<boolean>>;
onSave?: () => void;
onCancel?: () => void;
setActiveLine: React.Dispatch<React.SetStateAction<number | undefined>>;
};
export default function ZoneEditPane({
@@ -52,6 +53,7 @@ export default function ZoneEditPane({
setIsLoading,
onSave,
onCancel,
setActiveLine,
}: ZoneEditPaneProps) {
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
@@ -80,69 +82,144 @@ export default function ZoneEditPane({
}
}, [polygon, 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) => {
const otherPolygonNames =
polygons
?.filter((_, index) => index !== activePolygonIndex)
.map((polygon) => polygon.name) || [];
const [lineA, lineB, lineC, lineD] = useMemo(() => {
const distances =
polygon?.camera &&
polygon?.name &&
config?.cameras[polygon.camera]?.zones[polygon.name]?.distances;
return !otherPolygonNames.includes(value);
},
{
message: "Zone name already exists on this camera.",
},
)
.refine(
(value: string) => {
return !value.includes(".");
},
{
message: "Zone name must not contain a period.",
},
)
.refine((value: string) => /^[a-zA-Z0-9_-]+$/.test(value), {
message: "Zone name has an illegal character.",
return Array.isArray(distances)
? distances.map((value) => parseFloat(value) || 0)
: [undefined, undefined, undefined, undefined];
}, [polygon, 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) => {
const otherPolygonNames =
polygons
?.filter((_, index) => index !== activePolygonIndex)
.map((polygon) => polygon.name) || [];
return !otherPolygonNames.includes(value);
},
{
message: "Zone name already exists on this camera.",
},
)
.refine(
(value: string) => {
return !value.includes(".");
},
{
message: "Zone name must not contain a period.",
},
)
.refine((value: string) => /^[a-zA-Z0-9_-]+$/.test(value), {
message: "Zone name has an illegal character.",
}),
inertia: z.coerce
.number()
.min(1, {
message: "Inertia must be above 0.",
})
.or(z.literal("")),
loitering_time: z.coerce
.number()
.min(0, {
message: "Loitering time must be greater than or equal to 0.",
})
.optional()
.or(z.literal("")),
isFinished: z.boolean().refine(() => polygon?.isFinished === true, {
message: "The polygon drawing must be finished before saving.",
}),
inertia: z.coerce
.number()
.min(1, {
message: "Inertia must be above 0.",
})
.or(z.literal("")),
loitering_time: z.coerce
.number()
.min(0, {
message: "Loitering time must be greater than or equal to 0.",
})
.optional()
.or(z.literal("")),
isFinished: z.boolean().refine(() => polygon?.isFinished === true, {
message: "The polygon drawing must be finished before saving.",
}),
objects: z.array(z.string()).optional(),
review_alerts: z.boolean().default(false).optional(),
review_detections: z.boolean().default(false).optional(),
});
objects: z.array(z.string()).optional(),
review_alerts: z.boolean().default(false).optional(),
review_detections: z.boolean().default(false).optional(),
speedEstimation: z.boolean().default(false),
lineA: z.coerce
.number()
.min(0.1, {
message: "Distance must be greater than or equal to 0.1",
})
.optional()
.or(z.literal("")),
lineB: z.coerce
.number()
.min(0.1, {
message: "Distance must be greater than or equal to 0.1",
})
.optional()
.or(z.literal("")),
lineC: z.coerce
.number()
.min(0.1, {
message: "Distance must be greater than or equal to 0.1",
})
.optional()
.or(z.literal("")),
lineD: z.coerce
.number()
.min(0.1, {
message: "Distance must be greater than or equal to 0.1",
})
.optional()
.or(z.literal("")),
speed_threshold: z.coerce
.number()
.min(0.1, {
message: "Speed threshold must be greater than or equal to 0.1",
})
.optional()
.or(z.literal("")),
})
.refine(
(data) => {
if (data.speedEstimation) {
return !!data.lineA && !!data.lineB && !!data.lineC && !!data.lineD;
}
return true;
},
{
message: "All distance fields must be filled to use speed estimation.",
path: ["speedEstimation"],
},
)
.refine(
(data) => {
// Prevent speed estimation when loitering_time is greater than 0
return !(
data.speedEstimation &&
data.loitering_time &&
data.loitering_time > 0
);
},
{
message:
"Zones with loitering times greater than 0 should not be used with speed estimation.",
path: ["loitering_time"],
},
);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
mode: "onBlur",
defaultValues: {
name: polygon?.name ?? "",
inertia:
@@ -155,9 +232,31 @@ export default function ZoneEditPane({
config?.cameras[polygon.camera]?.zones[polygon.name]?.loitering_time,
isFinished: polygon?.isFinished ?? false,
objects: polygon?.objects ?? [],
speedEstimation: !!(lineA || lineB || lineC || lineD),
lineA,
lineB,
lineC,
lineD,
speed_threshold:
polygon?.camera &&
polygon?.name &&
config?.cameras[polygon.camera]?.zones[polygon.name]?.speed_threshold,
},
});
useEffect(() => {
if (
form.watch("speedEstimation") &&
polygon &&
polygon.points.length !== 4
) {
toast.error(
"Speed estimation has been disabled for this zone. Zones with speed estimation must have exactly 4 points.",
);
form.setValue("speedEstimation", false);
}
}, [polygon, form]);
const saveToConfig = useCallback(
async (
{
@@ -165,6 +264,12 @@ export default function ZoneEditPane({
inertia,
loitering_time,
objects: form_objects,
speedEstimation,
lineA,
lineB,
lineC,
lineD,
speed_threshold,
}: ZoneFormValuesType, // values submitted via the form
objects: string[],
) => {
@@ -261,9 +366,32 @@ export default function ZoneEditPane({
loiteringTimeQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.loitering_time=${loitering_time}`;
}
let distancesQuery = "";
const distances = [lineA, lineB, lineC, lineD].filter(Boolean).join(",");
if (speedEstimation) {
distancesQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.distances=${distances}`;
} else {
if (distances != "") {
distancesQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.distances`;
}
}
let speedThresholdQuery = "";
if (speed_threshold >= 0 && speedEstimation) {
speedThresholdQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.speed_threshold=${speed_threshold}`;
} else {
if (
polygon?.camera &&
polygon?.name &&
config?.cameras[polygon.camera]?.zones[polygon.name]?.speed_threshold
) {
speedThresholdQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.speed_threshold`;
}
}
axios
.put(
`config/set?cameras.${polygon?.camera}.zones.${zoneName}.coordinates=${coordinates}${inertiaQuery}${loiteringTimeQuery}${objectQueries}${alertQueries}${detectionQueries}`,
`config/set?cameras.${polygon?.camera}.zones.${zoneName}.coordinates=${coordinates}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${alertQueries}${detectionQueries}`,
{ requires_restart: 0 },
)
.then((res) => {
@@ -456,6 +584,183 @@ export default function ZoneEditPane({
/>
</FormItem>
<Separator className="my-2 flex bg-secondary" />
<FormField
control={form.control}
name="speedEstimation"
render={({ field }) => (
<FormItem>
<div className="flex items-center space-x-2">
<FormControl>
<div className="my-2.5 flex w-full items-center justify-between">
<FormLabel
className="cursor-pointer text-primary"
htmlFor="allLabels"
>
Speed Estimation
</FormLabel>
<Switch
checked={field.value}
onCheckedChange={(checked) => {
if (
checked &&
polygons &&
activePolygonIndex &&
polygons[activePolygonIndex].points.length !== 4
) {
toast.error(
"Zones with speed estimation must have exactly 4 points.",
);
return;
}
const loiteringTime =
form.getValues("loitering_time");
if (checked && loiteringTime && loiteringTime > 0) {
toast.error(
"Zones with loitering times greater than 0 should not be used with speed estimation.",
);
}
field.onChange(checked);
}}
/>
</div>
</FormControl>
</div>
<FormDescription>
Enable speed estimation for objects in this zone. The zone
must have exactly 4 points.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{form.watch("speedEstimation") &&
polygons &&
activePolygonIndex &&
polygons[activePolygonIndex].points.length === 4 && (
<>
<FormField
control={form.control}
name="lineA"
render={({ field }) => (
<FormItem>
<FormLabel>
Line A distance (
{config?.ui.unit_system == "imperial"
? "feet"
: "meters"}
)
</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
{...field}
onFocus={() => setActiveLine(1)}
onBlur={() => setActiveLine(undefined)}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="lineB"
render={({ field }) => (
<FormItem>
<FormLabel>
Line B distance (
{config?.ui.unit_system == "imperial"
? "feet"
: "meters"}
)
</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
{...field}
onFocus={() => setActiveLine(2)}
onBlur={() => setActiveLine(undefined)}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="lineC"
render={({ field }) => (
<FormItem>
<FormLabel>
Line C distance (
{config?.ui.unit_system == "imperial"
? "feet"
: "meters"}
)
</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
{...field}
onFocus={() => setActiveLine(3)}
onBlur={() => setActiveLine(undefined)}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="lineD"
render={({ field }) => (
<FormItem>
<FormLabel>
Line D distance (
{config?.ui.unit_system == "imperial"
? "feet"
: "meters"}
)
</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
{...field}
onFocus={() => setActiveLine(4)}
onBlur={() => setActiveLine(undefined)}
/>
</FormControl>
</FormItem>
)}
/>
<Separator className="my-2 flex bg-secondary" />
<FormField
control={form.control}
name="speed_threshold"
render={({ field }) => (
<FormItem>
<FormLabel>
Speed Threshold (
{config?.ui.unit_system == "imperial" ? "mph" : "kph"})
</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
{...field}
/>
</FormControl>
<FormDescription>
Specifies a minimum speed for objects to be considered
in this zone.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<FormField
control={form.control}
name="isFinished"
@@ -557,7 +862,9 @@ export function ZoneObjectSelector({
useEffect(() => {
updateLabelFilter(currentLabels);
}, [currentLabels, updateLabelFilter]);
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentLabels]);
return (
<>