mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-31 16:12:19 +03:00
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:
@@ -116,6 +116,9 @@ export default function SearchFilterGroup({
|
||||
if (filter?.min_score || filter?.max_score) {
|
||||
sortTypes.push("score_desc", "score_asc");
|
||||
}
|
||||
if (filter?.min_speed || filter?.max_speed) {
|
||||
sortTypes.push("speed_desc", "speed_asc");
|
||||
}
|
||||
if (filter?.event_id || filter?.query) {
|
||||
sortTypes.push("relevance");
|
||||
}
|
||||
@@ -498,6 +501,8 @@ export function SortTypeContent({
|
||||
date_desc: "Date (Descending)",
|
||||
score_asc: "Object Score (Ascending)",
|
||||
score_desc: "Object Score (Descending)",
|
||||
speed_asc: "Estimated Speed (Ascending)",
|
||||
speed_desc: "Estimated Speed (Descending)",
|
||||
relevance: "Relevance",
|
||||
};
|
||||
|
||||
|
||||
@@ -216,11 +216,14 @@ export default function InputWithTags({
|
||||
type == "after" ||
|
||||
type == "time_range" ||
|
||||
type == "min_score" ||
|
||||
type == "max_score"
|
||||
type == "max_score" ||
|
||||
type == "min_speed" ||
|
||||
type == "max_speed"
|
||||
) {
|
||||
const newFilters = { ...filters };
|
||||
let timestamp = 0;
|
||||
let score = 0;
|
||||
let speed = 0;
|
||||
|
||||
switch (type) {
|
||||
case "before":
|
||||
@@ -294,6 +297,40 @@ export default function InputWithTags({
|
||||
newFilters[type] = score / 100;
|
||||
}
|
||||
break;
|
||||
case "min_speed":
|
||||
case "max_speed":
|
||||
speed = parseFloat(value);
|
||||
if (score >= 0) {
|
||||
// Check for conflicts between min_speed and max_speed
|
||||
if (
|
||||
type === "min_speed" &&
|
||||
filters.max_speed !== undefined &&
|
||||
speed > filters.max_speed
|
||||
) {
|
||||
toast.error(
|
||||
"The 'min_speed' must be less than or equal to the 'max_speed'.",
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
type === "max_speed" &&
|
||||
filters.min_speed !== undefined &&
|
||||
speed < filters.min_speed
|
||||
) {
|
||||
toast.error(
|
||||
"The 'max_speed' must be greater than or equal to the 'min_speed'.",
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
newFilters[type] = speed;
|
||||
}
|
||||
break;
|
||||
case "time_range":
|
||||
newFilters[type] = value;
|
||||
break;
|
||||
@@ -369,6 +406,10 @@ export default function InputWithTags({
|
||||
}`;
|
||||
} else if (filterType === "min_score" || filterType === "max_score") {
|
||||
return Math.round(Number(filterValues) * 100).toString() + "%";
|
||||
} else if (filterType === "min_speed" || filterType === "max_speed") {
|
||||
return (
|
||||
filterValues + (config?.ui.unit_system == "metric" ? " kph" : " mph")
|
||||
);
|
||||
} else if (
|
||||
filterType === "has_clip" ||
|
||||
filterType === "has_snapshot" ||
|
||||
@@ -397,7 +438,11 @@ export default function InputWithTags({
|
||||
((filterType === "min_score" || filterType === "max_score") &&
|
||||
!isNaN(Number(trimmedValue)) &&
|
||||
Number(trimmedValue) >= 50 &&
|
||||
Number(trimmedValue) <= 100)
|
||||
Number(trimmedValue) <= 100) ||
|
||||
((filterType === "min_speed" || filterType === "max_speed") &&
|
||||
!isNaN(Number(trimmedValue)) &&
|
||||
Number(trimmedValue) >= 1 &&
|
||||
Number(trimmedValue) <= 150)
|
||||
) {
|
||||
createFilter(
|
||||
filterType,
|
||||
|
||||
@@ -25,6 +25,7 @@ import { baseUrl } from "@/api/baseUrl";
|
||||
import { cn } from "@/lib/utils";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import {
|
||||
FaArrowRight,
|
||||
FaCheckCircle,
|
||||
FaChevronDown,
|
||||
FaDownload,
|
||||
@@ -329,6 +330,30 @@ function ObjectDetailsTab({
|
||||
}
|
||||
}, [search]);
|
||||
|
||||
const averageEstimatedSpeed = useMemo(() => {
|
||||
if (!search || !search.data?.average_estimated_speed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (search.data?.average_estimated_speed != 0) {
|
||||
return search.data?.average_estimated_speed.toFixed(1);
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}, [search]);
|
||||
|
||||
const velocityAngle = useMemo(() => {
|
||||
if (!search || !search.data?.velocity_angle) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (search.data?.velocity_angle != 0) {
|
||||
return search.data?.velocity_angle.toFixed(1);
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}, [search]);
|
||||
|
||||
const updateDescription = useCallback(() => {
|
||||
if (!search) {
|
||||
return;
|
||||
@@ -440,6 +465,29 @@ function ObjectDetailsTab({
|
||||
{score}%{subLabelScore && ` (${subLabelScore}%)`}
|
||||
</div>
|
||||
</div>
|
||||
{averageEstimatedSpeed && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="text-sm text-primary/40">Estimated Speed</div>
|
||||
<div className="flex flex-col space-y-0.5 text-sm">
|
||||
{averageEstimatedSpeed && (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{averageEstimatedSpeed}{" "}
|
||||
{config?.ui.unit_system == "imperial" ? "mph" : "kph"}{" "}
|
||||
{velocityAngle != undefined && (
|
||||
<span className="text-primary/40">
|
||||
<FaArrowRight
|
||||
size={10}
|
||||
style={{
|
||||
transform: `rotate(${(360 - Number(velocityAngle)) % 360}deg)`,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="text-sm text-primary/40">Camera</div>
|
||||
<div className="text-sm capitalize">
|
||||
|
||||
@@ -71,9 +71,11 @@ export default function SearchFilterDialog({
|
||||
currentFilter &&
|
||||
(currentFilter.time_range ||
|
||||
(currentFilter.min_score ?? 0) > 0.5 ||
|
||||
(currentFilter.min_speed ?? 1) > 1 ||
|
||||
(currentFilter.has_snapshot ?? 0) === 1 ||
|
||||
(currentFilter.has_clip ?? 0) === 1 ||
|
||||
(currentFilter.max_score ?? 1) < 1 ||
|
||||
(currentFilter.max_speed ?? 150) < 150 ||
|
||||
(currentFilter.zones?.length ?? 0) > 0 ||
|
||||
(currentFilter.sub_labels?.length ?? 0) > 0),
|
||||
[currentFilter],
|
||||
@@ -124,6 +126,14 @@ export default function SearchFilterDialog({
|
||||
setCurrentFilter({ ...currentFilter, min_score: min, max_score: max })
|
||||
}
|
||||
/>
|
||||
<SpeedFilterContent
|
||||
config={config}
|
||||
minSpeed={currentFilter.min_speed}
|
||||
maxSpeed={currentFilter.max_speed}
|
||||
setSpeedRange={(min, max) =>
|
||||
setCurrentFilter({ ...currentFilter, min_speed: min, max_speed: max })
|
||||
}
|
||||
/>
|
||||
<SnapshotClipFilterContent
|
||||
config={config}
|
||||
hasSnapshot={
|
||||
@@ -178,6 +188,8 @@ export default function SearchFilterDialog({
|
||||
search_type: undefined,
|
||||
min_score: undefined,
|
||||
max_score: undefined,
|
||||
min_speed: undefined,
|
||||
max_speed: undefined,
|
||||
has_snapshot: undefined,
|
||||
has_clip: undefined,
|
||||
}));
|
||||
@@ -521,6 +533,62 @@ export function ScoreFilterContent({
|
||||
);
|
||||
}
|
||||
|
||||
type SpeedFilterContentProps = {
|
||||
config?: FrigateConfig;
|
||||
minSpeed: number | undefined;
|
||||
maxSpeed: number | undefined;
|
||||
setSpeedRange: (min: number | undefined, max: number | undefined) => void;
|
||||
};
|
||||
export function SpeedFilterContent({
|
||||
config,
|
||||
minSpeed,
|
||||
maxSpeed,
|
||||
setSpeedRange,
|
||||
}: SpeedFilterContentProps) {
|
||||
return (
|
||||
<div className="overflow-x-hidden">
|
||||
<DropdownMenuSeparator className="mb-3" />
|
||||
<div className="mb-3 text-lg">
|
||||
Estimated Speed ({config?.ui.unit_system == "metric" ? "kph" : "mph"})
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
className="w-14 text-center"
|
||||
inputMode="numeric"
|
||||
value={minSpeed ?? 1}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
|
||||
if (value) {
|
||||
setSpeedRange(parseInt(value), maxSpeed ?? 1.0);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<DualThumbSlider
|
||||
className="mx-2 w-full"
|
||||
min={1}
|
||||
max={150}
|
||||
step={1}
|
||||
value={[minSpeed ?? 1, maxSpeed ?? 150]}
|
||||
onValueChange={([min, max]) => setSpeedRange(min, max)}
|
||||
/>
|
||||
<Input
|
||||
className="w-14 text-center"
|
||||
inputMode="numeric"
|
||||
value={maxSpeed ?? 150}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
|
||||
if (value) {
|
||||
setSpeedRange(minSpeed ?? 1, parseInt(value));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SnapshotClipContentProps = {
|
||||
config?: FrigateConfig;
|
||||
hasSnapshot: boolean | undefined;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -112,6 +112,8 @@ export default function Explore() {
|
||||
search_type: searchSearchParams["search_type"],
|
||||
min_score: searchSearchParams["min_score"],
|
||||
max_score: searchSearchParams["max_score"],
|
||||
min_speed: searchSearchParams["min_speed"],
|
||||
max_speed: searchSearchParams["max_speed"],
|
||||
has_snapshot: searchSearchParams["has_snapshot"],
|
||||
is_submitted: searchSearchParams["is_submitted"],
|
||||
has_clip: searchSearchParams["has_clip"],
|
||||
@@ -145,6 +147,8 @@ export default function Explore() {
|
||||
search_type: searchSearchParams["search_type"],
|
||||
min_score: searchSearchParams["min_score"],
|
||||
max_score: searchSearchParams["max_score"],
|
||||
min_speed: searchSearchParams["min_speed"],
|
||||
max_speed: searchSearchParams["max_speed"],
|
||||
has_snapshot: searchSearchParams["has_snapshot"],
|
||||
is_submitted: searchSearchParams["is_submitted"],
|
||||
has_clip: searchSearchParams["has_clip"],
|
||||
|
||||
@@ -8,6 +8,7 @@ export type Polygon = {
|
||||
objects: string[];
|
||||
points: number[][];
|
||||
pointsOrder?: number[];
|
||||
distances: number[];
|
||||
isFinished: boolean;
|
||||
color: number[];
|
||||
};
|
||||
@@ -18,6 +19,12 @@ export type ZoneFormValuesType = {
|
||||
loitering_time: number;
|
||||
isFinished: boolean;
|
||||
objects: string[];
|
||||
speedEstimation: boolean;
|
||||
lineA: number;
|
||||
lineB: number;
|
||||
lineC: number;
|
||||
lineD: number;
|
||||
speed_threshold: number;
|
||||
};
|
||||
|
||||
export type ObjectMaskFormValuesType = {
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface UiConfig {
|
||||
strftime_fmt?: string;
|
||||
dashboard: boolean;
|
||||
order: number;
|
||||
unit_system?: "metric" | "imperial";
|
||||
}
|
||||
|
||||
export interface BirdseyeConfig {
|
||||
@@ -223,9 +224,11 @@ export interface CameraConfig {
|
||||
zones: {
|
||||
[zoneName: string]: {
|
||||
coordinates: string;
|
||||
distances: string[];
|
||||
filters: Record<string, unknown>;
|
||||
inertia: number;
|
||||
loitering_time: number;
|
||||
speed_threshold: number;
|
||||
objects: string[];
|
||||
color: number[];
|
||||
};
|
||||
|
||||
@@ -55,6 +55,8 @@ export type SearchResult = {
|
||||
ratio: number;
|
||||
type: "object" | "audio" | "manual";
|
||||
description?: string;
|
||||
average_estimated_speed: number;
|
||||
velocity_angle: number;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -68,6 +70,8 @@ export type SearchFilter = {
|
||||
after?: number;
|
||||
min_score?: number;
|
||||
max_score?: number;
|
||||
min_speed?: number;
|
||||
max_speed?: number;
|
||||
has_snapshot?: number;
|
||||
has_clip?: number;
|
||||
is_submitted?: number;
|
||||
@@ -89,6 +93,8 @@ export type SearchQueryParams = {
|
||||
after?: string;
|
||||
min_score?: number;
|
||||
max_score?: number;
|
||||
min_speed?: number;
|
||||
max_speed?: number;
|
||||
search_type?: string;
|
||||
limit?: number;
|
||||
in_progress?: number;
|
||||
|
||||
@@ -158,6 +158,8 @@ export default function SearchView({
|
||||
after: [formatDateToLocaleString(-5)],
|
||||
min_score: ["50"],
|
||||
max_score: ["100"],
|
||||
min_speed: ["1"],
|
||||
max_speed: ["150"],
|
||||
has_clip: ["yes", "no"],
|
||||
has_snapshot: ["yes", "no"],
|
||||
...(config?.plus?.enabled &&
|
||||
|
||||
@@ -61,6 +61,7 @@ export default function MasksAndZonesView({
|
||||
);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [editPane, setEditPane] = useState<PolygonType | undefined>(undefined);
|
||||
const [activeLine, setActiveLine] = useState<number | undefined>();
|
||||
|
||||
const { addMessage } = useContext(StatusBarMessagesContext)!;
|
||||
|
||||
@@ -161,6 +162,7 @@ export default function MasksAndZonesView({
|
||||
...(allPolygons || []),
|
||||
{
|
||||
points: [],
|
||||
distances: [],
|
||||
isFinished: false,
|
||||
type,
|
||||
typeIndex: 9999,
|
||||
@@ -238,6 +240,8 @@ export default function MasksAndZonesView({
|
||||
scaledWidth,
|
||||
scaledHeight,
|
||||
),
|
||||
distances:
|
||||
zoneData.distances?.map((distance) => parseFloat(distance)) ?? [],
|
||||
isFinished: true,
|
||||
color: zoneData.color,
|
||||
}),
|
||||
@@ -267,6 +271,7 @@ export default function MasksAndZonesView({
|
||||
scaledWidth,
|
||||
scaledHeight,
|
||||
),
|
||||
distances: [],
|
||||
isFinished: true,
|
||||
color: [0, 0, 255],
|
||||
}));
|
||||
@@ -290,6 +295,7 @@ export default function MasksAndZonesView({
|
||||
scaledWidth,
|
||||
scaledHeight,
|
||||
),
|
||||
distances: [],
|
||||
isFinished: true,
|
||||
color: [128, 128, 128],
|
||||
}));
|
||||
@@ -316,6 +322,7 @@ export default function MasksAndZonesView({
|
||||
scaledWidth,
|
||||
scaledHeight,
|
||||
),
|
||||
distances: [],
|
||||
isFinished: true,
|
||||
color: [128, 128, 128],
|
||||
};
|
||||
@@ -391,6 +398,7 @@ export default function MasksAndZonesView({
|
||||
setIsLoading={setIsLoading}
|
||||
onCancel={handleCancel}
|
||||
onSave={handleSave}
|
||||
setActiveLine={setActiveLine}
|
||||
/>
|
||||
)}
|
||||
{editPane == "motion_mask" && (
|
||||
@@ -653,6 +661,7 @@ export default function MasksAndZonesView({
|
||||
activePolygonIndex={activePolygonIndex}
|
||||
hoveredPolygonIndex={hoveredPolygonIndex}
|
||||
selectedZoneMask={selectedZoneMask}
|
||||
activeLine={activeLine}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton className="size-full" />
|
||||
|
||||
Reference in New Issue
Block a user