From c88c01235a551966fc03406a34d152d56b183192 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sat, 7 Dec 2024 22:05:58 -0600 Subject: [PATCH 01/17] utility functions --- frigate/util/velocity.py | 111 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 frigate/util/velocity.py diff --git a/frigate/util/velocity.py b/frigate/util/velocity.py new file mode 100644 index 000000000..96f342d17 --- /dev/null +++ b/frigate/util/velocity.py @@ -0,0 +1,111 @@ +import math + +import numpy as np + +unit_system = "imperial" +magnitude = "mph" + + +def create_ground_plane(zone_points, distances): + """ + Create a ground plane that accounts for perspective distortion using + real-world dimensions for each side of the zone. + + :param zone_points: Array of zone corner points in pixel coordinates + [[x1, y1], [x2, y2], [x3, y3], [x4, y4]] + :param distances: Real-world dimensions ordered by top, bottom left, right + :return: Function that calculates real-world distance per pixel at any coordinate + """ + # Sort points by y coordinate to get top and bottom lines + sorted_points = zone_points[np.argsort(zone_points[:, 1])] + top_width, bottom_width, left_depth, right_depth = map(float, distances) + + top_line = sorted_points[:2] + bottom_line = sorted_points[2:] + + # Sort left to right for consistent indexing + top_line = top_line[np.argsort(top_line[:, 0])] + bottom_line = bottom_line[np.argsort(bottom_line[:, 0])] + + # Calculate pixel lengths of each side + top_width_px = np.linalg.norm(top_line[1] - top_line[0]) + bottom_width_px = np.linalg.norm(bottom_line[1] - bottom_line[0]) + left_depth_px = np.linalg.norm(bottom_line[0] - top_line[0]) + right_depth_px = np.linalg.norm(bottom_line[1] - top_line[1]) + + top_scale = top_width / top_width_px + bottom_scale = bottom_width / bottom_width_px + left_scale = left_depth / left_depth_px + right_scale = right_depth / right_depth_px + + def distance_per_pixel(x, y): + """ + Calculate the real-world distance per pixel at a given (x, y) coordinate. + + :param x: X-coordinate in the image + :param y: Y-coordinate in the image + :return: Real-world distance per pixel at the given (x, y) coordinate + """ + # Normalize x and y within the zone + x_norm = (x - top_line[0][0]) / (top_line[1][0] - top_line[0][0]) + y_norm = (y - top_line[0][1]) / (bottom_line[0][1] - top_line[0][1]) + + # Interpolate scales horizontally and vertically + top_to_bottom_scale = top_scale + (bottom_scale - top_scale) * y_norm + left_to_right_scale = left_scale + (right_scale - left_scale) * x_norm + + # Combine horizontal and vertical scales + return (top_to_bottom_scale + left_to_right_scale) / 2 + + return distance_per_pixel + + +def calculate_real_world_speed( + zone_contour, + distances, + velocity_pixels, + position, + camera_fps, +): + """ + Calculate the real-world speed of a tracked object, accounting for perspective, + directly from the zone string. + + :param zone_contour: Array of absolute zone points + :param distances: Comma separated distances of each side, ordered by top, bottom, left, right + :param velocity_pixels: List of tuples representing velocity in pixels/frame + :param position: Current position of the object (x, y) in pixels + :param camera_width: Width of the camera frame in pixels + :param camera_height: Height of the camera frame in pixels + :return: speed in the specified unit system (m/s for metric, ft/s for imperial) and velocity direction + """ + ground_plane = create_ground_plane(zone_contour, distances) + + if not isinstance(velocity_pixels, np.ndarray): + velocity_pixels = np.array(velocity_pixels) + + avg_velocity_pixels = velocity_pixels.mean(axis=0) + + # get the real-world distance per pixel at the object's current position and calculate real speed + scale = ground_plane(position[0], position[1]) + speed_real = avg_velocity_pixels * scale * camera_fps + + # euclidean speed in real-world units/second + speed_magnitude = np.linalg.norm(speed_real) + + # movement direction + dx, dy = avg_velocity_pixels + angle = math.degrees(math.atan2(dy, dx)) + if angle < 0: + angle += 360 + + if unit_system == "metric": + if magnitude == "kmh": + # Convert m/s to km/h + speed_magnitude *= 3.6 + elif unit_system == "imperial": + if magnitude == "mph": + # Convert ft/s to mph + speed_magnitude *= 0.681818 + + return speed_magnitude, angle From ae797a5c7c911abd02cc1035b6ed1d2fe819450c Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sat, 7 Dec 2024 22:06:41 -0600 Subject: [PATCH 02/17] backend config --- frigate/config/camera/zone.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/frigate/config/camera/zone.py b/frigate/config/camera/zone.py index 65b34a049..f2e92f3a8 100644 --- a/frigate/config/camera/zone.py +++ b/frigate/config/camera/zone.py @@ -16,6 +16,10 @@ class ZoneConfig(BaseModel): coordinates: Union[str, list[str]] = Field( title="Coordinates polygon for the defined zone." ) + distances: Optional[Union[str, list[str]]] = Field( + default_factory=list, + title="Real-world distances for the sides of quadrilateral for the defined zone.", + ) inertia: int = Field( default=3, title="Number of consecutive frames required for object to be considered present in the zone.", @@ -49,6 +53,24 @@ class ZoneConfig(BaseModel): return v + @field_validator("distances", mode="before") + @classmethod + def validate_distances(cls, v): + if v is None: + return None + + if isinstance(v, str): + distances = list(map(str, map(float, v.split(",")))) + elif isinstance(v, list): + distances = [str(float(val)) for val in v] + else: + raise ValueError("Invalid type for distances") + + if len(distances) != 4: + raise ValueError("distances must have exactly 4 values") + + return distances + def __init__(self, **config): super().__init__(**config) From f191dfc5974e9e9fc16d11eea248e696143ccabc Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sat, 7 Dec 2024 22:07:20 -0600 Subject: [PATCH 03/17] backend object speed tracking --- frigate/track/tracked_object.py | 36 ++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/frigate/track/tracked_object.py b/frigate/track/tracked_object.py index 65e7a2ed5..e695e7e63 100644 --- a/frigate/track/tracked_object.py +++ b/frigate/track/tracked_object.py @@ -21,6 +21,7 @@ from frigate.util.image import ( is_better_thumbnail, ) from frigate.util.object import box_inside +from frigate.util.velocity import calculate_real_world_speed logger = logging.getLogger(__name__) @@ -57,6 +58,9 @@ class TrackedObject: self.frame = None self.active = True self.pending_loitering = False + self.estimated_speed = 0 + self.max_estimated_speed = 0 + self.velocity_angle = 0 self.previous = self.to_dict() def _is_false_positive(self): @@ -107,6 +111,7 @@ class TrackedObject: "region": obj_data["region"], "score": obj_data["score"], "attributes": obj_data["attributes"], + "estimated_speed": self.estimated_speed, } thumb_update = True @@ -152,6 +157,26 @@ class TrackedObject: if 0 < zone_score < zone.inertia: self.zone_presence[name] = zone_score - 1 + # update speed + if zone.distances and name in self.entered_zones: + self.estimated_speed, self.velocity_angle = ( + calculate_real_world_speed( + zone.contour, + zone.distances, + self.obj_data["estimate_velocity"], + bottom_center, + self.camera_config.detect.fps, + ) + if self.active + else 0 + ) + logger.debug( + f"Camera: {self.camera_config.name}, zone: {name}, tracked object ID: {self.obj_data['id']}, estimated speed: {self.estimated_speed:.1f}" + ) + + if self.estimated_speed > self.max_estimated_speed: + self.max_estimated_speed = self.estimated_speed + # update loitering status self.pending_loitering = in_loitering_zone @@ -232,6 +257,14 @@ class TrackedObject: "attributes": self.attributes, "current_attributes": self.obj_data["attributes"], "pending_loitering": self.pending_loitering, + "pixel_velocity": str( + tuple( + np.round(self.obj_data["estimate_velocity"]).flatten().astype(int) + ) + ), + "estimated_speed": self.estimated_speed, + "max_estimated_speed": self.max_estimated_speed, + "velocity_angle": self.velocity_angle, } if include_thumbnail: @@ -316,7 +349,8 @@ class TrackedObject: box[2], box[3], self.obj_data["label"], - f"{int(self.thumbnail_data['score']*100)}% {int(self.thumbnail_data['area'])}", + f"{int(self.thumbnail_data['score']*100)}% {int(self.thumbnail_data['area'])}" + + (f" {self.estimated_speed:.1f}" if self.estimated_speed != 0 else ""), thickness=thickness, color=color, ) From 6a3a519be6d6b8513693a5ab1110e052f6fda232 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sat, 7 Dec 2024 22:07:36 -0600 Subject: [PATCH 04/17] draw speed on debug view --- frigate/object_processing.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frigate/object_processing.py b/frigate/object_processing.py index ef23c3de3..a718ef2c7 100644 --- a/frigate/object_processing.py +++ b/frigate/object_processing.py @@ -162,7 +162,12 @@ class CameraState: box[2], box[3], text, - f"{obj['score']:.0%} {int(obj['area'])}", + f"{obj['score']:.0%} {int(obj['area'])}" + + ( + f" {float(obj['estimated_speed']):.1f}" + if obj["estimated_speed"] != 0 + else "" + ), thickness=thickness, color=color, ) From 23133c403216eae77ae1a9876f3bb180da9448e7 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sat, 7 Dec 2024 22:08:05 -0600 Subject: [PATCH 05/17] basic frontend zone editor --- web/src/components/settings/ZoneEditPane.tsx | 161 ++++++++++++++++++- web/src/types/canvas.ts | 5 + web/src/types/frigateConfig.ts | 1 + 3 files changed, 166 insertions(+), 1 deletion(-) diff --git a/web/src/components/settings/ZoneEditPane.tsx b/web/src/components/settings/ZoneEditPane.tsx index 54799db72..a7a568b01 100644 --- a/web/src/components/settings/ZoneEditPane.tsx +++ b/web/src/components/settings/ZoneEditPane.tsx @@ -80,6 +80,17 @@ export default function ZoneEditPane({ } }, [polygon, config]); + const [topWidth, bottomWidth, leftDepth, rightDepth] = useMemo(() => { + const distances = + polygon?.camera && + polygon?.name && + config?.cameras[polygon.camera]?.zones[polygon.name]?.distances; + + return Array.isArray(distances) + ? distances.map((value) => parseFloat(value) || 0) + : [0, 0, 0, 0]; + }, [polygon, config]); + const formSchema = z.object({ name: z .string() @@ -138,6 +149,35 @@ export default function ZoneEditPane({ 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), + topWidth: z.coerce + .number() + .min(0.1, { + message: "Distance must be greater than or equal to 0.1", + }) + .optional() + .or(z.literal("")), + bottomWidth: z.coerce + .number() + .min(0.1, { + message: "Distance must be greater than or equal to 0.1", + }) + .optional() + .or(z.literal("")), + leftDepth: z.coerce + .number() + .min(0.1, { + message: "Distance must be greater than or equal to 0.1", + }) + .optional() + .or(z.literal("")), + rightDepth: z.coerce + .number() + .min(0.1, { + message: "Distance must be greater than or equal to 0.1", + }) + .optional() + .or(z.literal("")), }); const form = useForm>({ @@ -155,6 +195,11 @@ export default function ZoneEditPane({ config?.cameras[polygon.camera]?.zones[polygon.name]?.loitering_time, isFinished: polygon?.isFinished ?? false, objects: polygon?.objects ?? [], + speedEstimation: !!(topWidth || bottomWidth || leftDepth || rightDepth), + topWidth, + bottomWidth, + leftDepth, + rightDepth, }, }); @@ -165,6 +210,11 @@ export default function ZoneEditPane({ inertia, loitering_time, objects: form_objects, + speedEstimation, + topWidth, + bottomWidth, + leftDepth, + rightDepth, }: ZoneFormValuesType, // values submitted via the form objects: string[], ) => { @@ -261,9 +311,19 @@ export default function ZoneEditPane({ loiteringTimeQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.loitering_time=${loitering_time}`; } + let distancesQuery = ""; + const distances = [topWidth, bottomWidth, leftDepth, rightDepth].join( + ",", + ); + if (speedEstimation) { + distancesQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.distances=${distances}`; + } else { + distancesQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.distances`; + } + 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}${distancesQuery}${objectQueries}${alertQueries}${detectionQueries}`, { requires_restart: 0 }, ) .then((res) => { @@ -456,6 +516,105 @@ export default function ZoneEditPane({ /> + + ( + +
+ +
+ + Speed Estimation + + { + if ( + checked && + polygons && + activePolygonIndex && + polygons[activePolygonIndex].points.length !== 4 + ) { + toast.error( + "Zones with speed estimation must have exactly 4 points", + ); + return; + } + field.onChange(checked); + }} + /> +
+
+
+ + Enable speed estimation for objects in this zone. The zone + must have exactly 4 points. + +
+ )} + /> + + {form.watch("speedEstimation") && + polygons && + activePolygonIndex && + polygons[activePolygonIndex].points.length === 4 && ( + <> + ( + + Top Width + + + + + )} + /> + ( + + Bottom Width + + + + + )} + /> + ( + + Left Depth + + + + + )} + /> + ( + + Right Depth + + + + + )} + /> + + )} + ; inertia: number; loitering_time: number; From d263026ab4882bbb7fc71d7b1e4608eeefb93082 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 8 Dec 2024 12:01:58 -0600 Subject: [PATCH 06/17] remove line sorting --- frigate/util/velocity.py | 51 +++++++++++++++++----------------------- 1 file changed, 21 insertions(+), 30 deletions(-) diff --git a/frigate/util/velocity.py b/frigate/util/velocity.py index 96f342d17..276c9815c 100644 --- a/frigate/util/velocity.py +++ b/frigate/util/velocity.py @@ -8,35 +8,27 @@ magnitude = "mph" def create_ground_plane(zone_points, distances): """ - Create a ground plane that accounts for perspective distortion using - real-world dimensions for each side of the zone. + Create a ground plane that accounts for perspective distortion using real-world dimensions for each side of the zone. - :param zone_points: Array of zone corner points in pixel coordinates + :param zone_points: Array of zone corner points in pixel coordinates in circular order [[x1, y1], [x2, y2], [x3, y3], [x4, y4]] - :param distances: Real-world dimensions ordered by top, bottom left, right + :param distances: Real-world dimensions ordered by A, B, C, D :return: Function that calculates real-world distance per pixel at any coordinate """ - # Sort points by y coordinate to get top and bottom lines - sorted_points = zone_points[np.argsort(zone_points[:, 1])] - top_width, bottom_width, left_depth, right_depth = map(float, distances) - - top_line = sorted_points[:2] - bottom_line = sorted_points[2:] - - # Sort left to right for consistent indexing - top_line = top_line[np.argsort(top_line[:, 0])] - bottom_line = bottom_line[np.argsort(bottom_line[:, 0])] + A, B, C, D = zone_points # Calculate pixel lengths of each side - top_width_px = np.linalg.norm(top_line[1] - top_line[0]) - bottom_width_px = np.linalg.norm(bottom_line[1] - bottom_line[0]) - left_depth_px = np.linalg.norm(bottom_line[0] - top_line[0]) - right_depth_px = np.linalg.norm(bottom_line[1] - top_line[1]) + AB_px = np.linalg.norm(np.array(B) - np.array(A)) + BC_px = np.linalg.norm(np.array(C) - np.array(B)) + CD_px = np.linalg.norm(np.array(D) - np.array(C)) + DA_px = np.linalg.norm(np.array(A) - np.array(D)) - top_scale = top_width / top_width_px - bottom_scale = bottom_width / bottom_width_px - left_scale = left_depth / left_depth_px - right_scale = right_depth / right_depth_px + AB, BC, CD, DA = map(float, distances) + + AB_scale = AB / AB_px + BC_scale = BC / BC_px + CD_scale = CD / CD_px + DA_scale = DA / DA_px def distance_per_pixel(x, y): """ @@ -47,15 +39,15 @@ def create_ground_plane(zone_points, distances): :return: Real-world distance per pixel at the given (x, y) coordinate """ # Normalize x and y within the zone - x_norm = (x - top_line[0][0]) / (top_line[1][0] - top_line[0][0]) - y_norm = (y - top_line[0][1]) / (bottom_line[0][1] - top_line[0][1]) + x_norm = (x - A[0]) / (B[0] - A[0]) + y_norm = (y - A[1]) / (D[1] - A[1]) # Interpolate scales horizontally and vertically - top_to_bottom_scale = top_scale + (bottom_scale - top_scale) * y_norm - left_to_right_scale = left_scale + (right_scale - left_scale) * x_norm + vertical_scale = AB_scale + (CD_scale - AB_scale) * y_norm + horizontal_scale = DA_scale + (BC_scale - DA_scale) * x_norm # Combine horizontal and vertical scales - return (top_to_bottom_scale + left_to_right_scale) / 2 + return (vertical_scale + horizontal_scale) / 2 return distance_per_pixel @@ -72,11 +64,10 @@ def calculate_real_world_speed( directly from the zone string. :param zone_contour: Array of absolute zone points - :param distances: Comma separated distances of each side, ordered by top, bottom, left, right + :param distances: Comma separated distances of each side, ordered by A, B, C, D :param velocity_pixels: List of tuples representing velocity in pixels/frame :param position: Current position of the object (x, y) in pixels - :param camera_width: Width of the camera frame in pixels - :param camera_height: Height of the camera frame in pixels + :param camera_fps: Frames per second of the camera :return: speed in the specified unit system (m/s for metric, ft/s for imperial) and velocity direction """ ground_plane = create_ground_plane(zone_contour, distances) From 9be1454f108b0afbe2b1731de81759852a1c283b Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 8 Dec 2024 12:02:24 -0600 Subject: [PATCH 07/17] fix types --- web/src/types/canvas.ts | 1 + web/src/types/frigateConfig.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts index 0710a51df..045d5897d 100644 --- a/web/src/types/canvas.ts +++ b/web/src/types/canvas.ts @@ -8,6 +8,7 @@ export type Polygon = { objects: string[]; points: number[][]; pointsOrder?: number[]; + distances: number[]; isFinished: boolean; color: number[]; }; diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index 63295d86e..45bb39475 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -214,7 +214,7 @@ export interface CameraConfig { zones: { [zoneName: string]: { coordinates: string; - distances: string; + distances: string[]; filters: Record; inertia: number; loitering_time: number; From bb2a1e12cc75d08fa01ed58f6488b67c04e447d1 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 8 Dec 2024 12:03:18 -0600 Subject: [PATCH 08/17] highlight line on canvas when entering value in zone edit pane --- web/src/components/settings/PolygonCanvas.tsx | 6 ++ web/src/components/settings/PolygonDrawer.tsx | 78 ++++++++++++++++++- web/src/components/settings/ZoneEditPane.tsx | 34 ++++++-- web/src/views/settings/MasksAndZonesView.tsx | 8 ++ 4 files changed, 117 insertions(+), 9 deletions(-) diff --git a/web/src/components/settings/PolygonCanvas.tsx b/web/src/components/settings/PolygonCanvas.tsx index e6851b63c..d2a0a46b5 100644 --- a/web/src/components/settings/PolygonCanvas.tsx +++ b/web/src/components/settings/PolygonCanvas.tsx @@ -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(); @@ -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} /> )} diff --git a/web/src/components/settings/PolygonDrawer.tsx b/web/src/components/settings/PolygonDrawer.tsx index 966aad2ca..1ae3d4601 100644 --- a/web/src/components/settings/PolygonDrawer.tsx +++ b/web/src/components/settings/PolygonDrawer.tsx @@ -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; points: number[][]; + distances: number[]; isActive: boolean; isHovered: boolean; isFinished: boolean; color: number[]; handlePointDragMove: (e: KonvaEventObject) => void; handleGroupDragEnd: (e: KonvaEventObject) => 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 ( )} + {isActive && activeLinePoints.length > 0 && ( + + )} {points.map((point, index) => { if (!isActive) { return; @@ -195,6 +234,43 @@ export default function PolygonDrawer({ /> ); })} + {isFinished && ( + + {midpoints.map((midpoint, index) => { + const [x, y] = midpoint; + const distance = distances[index]; + if (distance === undefined) return null; + + const squareSize = 22; + + return ( + + + + + ); + })} + + )} ); } diff --git a/web/src/components/settings/ZoneEditPane.tsx b/web/src/components/settings/ZoneEditPane.tsx index a7a568b01..abdc90e93 100644 --- a/web/src/components/settings/ZoneEditPane.tsx +++ b/web/src/components/settings/ZoneEditPane.tsx @@ -40,6 +40,7 @@ type ZoneEditPaneProps = { setIsLoading: React.Dispatch>; onSave?: () => void; onCancel?: () => void; + setActiveLine: React.Dispatch>; }; export default function ZoneEditPane({ @@ -52,6 +53,7 @@ export default function ZoneEditPane({ setIsLoading, onSave, onCancel, + setActiveLine, }: ZoneEditPaneProps) { const { data: config, mutate: updateConfig } = useSWR("config"); @@ -569,9 +571,13 @@ export default function ZoneEditPane({ name="topWidth" render={({ field }) => ( - Top Width + Line A distance - + setActiveLine(1)} + onBlur={() => setActiveLine(undefined)} + /> )} @@ -581,9 +587,13 @@ export default function ZoneEditPane({ name="bottomWidth" render={({ field }) => ( - Bottom Width + Line B distance - + setActiveLine(2)} + onBlur={() => setActiveLine(undefined)} + /> )} @@ -593,9 +603,13 @@ export default function ZoneEditPane({ name="leftDepth" render={({ field }) => ( - Left Depth + Line C distance - + setActiveLine(3)} + onBlur={() => setActiveLine(undefined)} + /> )} @@ -605,9 +619,13 @@ export default function ZoneEditPane({ name="rightDepth" render={({ field }) => ( - Right Depth + Line D distance - + setActiveLine(4)} + onBlur={() => setActiveLine(undefined)} + /> )} diff --git a/web/src/views/settings/MasksAndZonesView.tsx b/web/src/views/settings/MasksAndZonesView.tsx index ab2646b5f..5c74a121b 100644 --- a/web/src/views/settings/MasksAndZonesView.tsx +++ b/web/src/views/settings/MasksAndZonesView.tsx @@ -61,6 +61,7 @@ export default function MasksAndZonesView({ ); const containerRef = useRef(null); const [editPane, setEditPane] = useState(undefined); + const [activeLine, setActiveLine] = useState(); const { addMessage } = useContext(StatusBarMessagesContext)!; @@ -161,6 +162,7 @@ export default function MasksAndZonesView({ ...(allPolygons || []), { points: [], + distances: [], isFinished: false, type, typeIndex: 9999, @@ -238,6 +240,7 @@ export default function MasksAndZonesView({ scaledWidth, scaledHeight, ), + distances: zoneData.distances.map((distance) => parseFloat(distance)), isFinished: true, color: zoneData.color, }), @@ -267,6 +270,7 @@ export default function MasksAndZonesView({ scaledWidth, scaledHeight, ), + distances: [], isFinished: true, color: [0, 0, 255], })); @@ -290,6 +294,7 @@ export default function MasksAndZonesView({ scaledWidth, scaledHeight, ), + distances: [], isFinished: true, color: [128, 128, 128], })); @@ -316,6 +321,7 @@ export default function MasksAndZonesView({ scaledWidth, scaledHeight, ), + distances: [], isFinished: true, color: [128, 128, 128], }; @@ -391,6 +397,7 @@ export default function MasksAndZonesView({ setIsLoading={setIsLoading} onCancel={handleCancel} onSave={handleSave} + setActiveLine={setActiveLine} /> )} {editPane == "motion_mask" && ( @@ -653,6 +660,7 @@ export default function MasksAndZonesView({ activePolygonIndex={activePolygonIndex} hoveredPolygonIndex={hoveredPolygonIndex} selectedZoneMask={selectedZoneMask} + activeLine={activeLine} /> ) : ( From e163a2f178b4d534e3ff1e62454f28e7bd68cfad Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 8 Dec 2024 12:27:12 -0600 Subject: [PATCH 09/17] rename vars and add validation --- web/src/components/settings/ZoneEditPane.tsx | 220 ++++++++++--------- web/src/types/canvas.ts | 8 +- 2 files changed, 120 insertions(+), 108 deletions(-) diff --git a/web/src/components/settings/ZoneEditPane.tsx b/web/src/components/settings/ZoneEditPane.tsx index abdc90e93..aee16ec60 100644 --- a/web/src/components/settings/ZoneEditPane.tsx +++ b/web/src/components/settings/ZoneEditPane.tsx @@ -82,7 +82,7 @@ export default function ZoneEditPane({ } }, [polygon, config]); - const [topWidth, bottomWidth, leftDepth, rightDepth] = useMemo(() => { + const [lineA, lineB, lineC, lineD] = useMemo(() => { const distances = polygon?.camera && polygon?.name && @@ -93,94 +93,107 @@ export default function ZoneEditPane({ : [0, 0, 0, 0]; }, [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 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.", + 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(), - speedEstimation: z.boolean().default(false), - topWidth: z.coerce - .number() - .min(0.1, { - message: "Distance must be greater than or equal to 0.1", - }) - .optional() - .or(z.literal("")), - bottomWidth: z.coerce - .number() - .min(0.1, { - message: "Distance must be greater than or equal to 0.1", - }) - .optional() - .or(z.literal("")), - leftDepth: z.coerce - .number() - .min(0.1, { - message: "Distance must be greater than or equal to 0.1", - }) - .optional() - .or(z.literal("")), - rightDepth: z.coerce - .number() - .min(0.1, { - message: "Distance must be greater than or equal to 0.1", - }) - .optional() - .or(z.literal("")), - }); + 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("")), + }) + .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"], + }, + ); const form = useForm>({ resolver: zodResolver(formSchema), @@ -197,11 +210,11 @@ export default function ZoneEditPane({ config?.cameras[polygon.camera]?.zones[polygon.name]?.loitering_time, isFinished: polygon?.isFinished ?? false, objects: polygon?.objects ?? [], - speedEstimation: !!(topWidth || bottomWidth || leftDepth || rightDepth), - topWidth, - bottomWidth, - leftDepth, - rightDepth, + speedEstimation: !!(lineA || lineB || lineC || lineD), + lineA, + lineB, + lineC, + lineD, }, }); @@ -213,10 +226,10 @@ export default function ZoneEditPane({ loitering_time, objects: form_objects, speedEstimation, - topWidth, - bottomWidth, - leftDepth, - rightDepth, + lineA, + lineB, + lineC, + lineD, }: ZoneFormValuesType, // values submitted via the form objects: string[], ) => { @@ -314,9 +327,7 @@ export default function ZoneEditPane({ } let distancesQuery = ""; - const distances = [topWidth, bottomWidth, leftDepth, rightDepth].join( - ",", - ); + const distances = [lineA, lineB, lineC, lineD].join(","); if (speedEstimation) { distancesQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.distances=${distances}`; } else { @@ -543,7 +554,7 @@ export default function ZoneEditPane({ polygons[activePolygonIndex].points.length !== 4 ) { toast.error( - "Zones with speed estimation must have exactly 4 points", + "Zones with speed estimation must have exactly 4 points.", ); return; } @@ -557,6 +568,7 @@ export default function ZoneEditPane({ Enable speed estimation for objects in this zone. The zone must have exactly 4 points. + )} /> @@ -568,7 +580,7 @@ export default function ZoneEditPane({ <> ( Line A distance @@ -584,7 +596,7 @@ export default function ZoneEditPane({ /> ( Line B distance @@ -600,7 +612,7 @@ export default function ZoneEditPane({ /> ( Line C distance @@ -616,7 +628,7 @@ export default function ZoneEditPane({ /> ( Line D distance diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts index 045d5897d..d6d9f84f7 100644 --- a/web/src/types/canvas.ts +++ b/web/src/types/canvas.ts @@ -20,10 +20,10 @@ export type ZoneFormValuesType = { isFinished: boolean; objects: string[]; speedEstimation: boolean; - topWidth: number; - bottomWidth: number; - leftDepth: number; - rightDepth: number; + lineA: number; + lineB: number; + lineC: number; + lineD: number; }; export type ObjectMaskFormValuesType = { From 6af95b74872df4e6446e3a30ba69ee4fc8ef804d Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 8 Dec 2024 13:11:23 -0600 Subject: [PATCH 10/17] ensure speed estimation is disabled when user adds more than 4 points --- web/src/components/settings/ZoneEditPane.tsx | 21 +++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/web/src/components/settings/ZoneEditPane.tsx b/web/src/components/settings/ZoneEditPane.tsx index aee16ec60..ec60086c5 100644 --- a/web/src/components/settings/ZoneEditPane.tsx +++ b/web/src/components/settings/ZoneEditPane.tsx @@ -90,7 +90,7 @@ export default function ZoneEditPane({ return Array.isArray(distances) ? distances.map((value) => parseFloat(value) || 0) - : [0, 0, 0, 0]; + : [undefined, undefined, undefined, undefined]; }, [polygon, config]); const formSchema = z @@ -197,7 +197,7 @@ export default function ZoneEditPane({ const form = useForm>({ resolver: zodResolver(formSchema), - mode: "onChange", + mode: "onBlur", defaultValues: { name: polygon?.name ?? "", inertia: @@ -218,6 +218,19 @@ export default function ZoneEditPane({ }, }); + 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 ( { @@ -746,7 +759,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 ( <> From 327522bc23a6ed8140e156dd610280b1bf08a3b3 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 8 Dec 2024 17:31:07 -0600 Subject: [PATCH 11/17] pixel velocity in debug --- frigate/track/tracked_object.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/frigate/track/tracked_object.py b/frigate/track/tracked_object.py index e695e7e63..a85912b8a 100644 --- a/frigate/track/tracked_object.py +++ b/frigate/track/tracked_object.py @@ -171,7 +171,9 @@ class TrackedObject: else 0 ) logger.debug( - f"Camera: {self.camera_config.name}, zone: {name}, tracked object ID: {self.obj_data['id']}, estimated speed: {self.estimated_speed:.1f}" + f"Camera: {self.camera_config.name}, zone: {name}, tracked object ID: {self.obj_data['id']}, \ + pixel velocity: {str(tuple(np.round(self.obj_data["estimate_velocity"]).flatten().astype(int)))} \ + estimated speed: {self.estimated_speed:.1f}" ) if self.estimated_speed > self.max_estimated_speed: @@ -257,11 +259,6 @@ class TrackedObject: "attributes": self.attributes, "current_attributes": self.obj_data["attributes"], "pending_loitering": self.pending_loitering, - "pixel_velocity": str( - tuple( - np.round(self.obj_data["estimate_velocity"]).flatten().astype(int) - ) - ), "estimated_speed": self.estimated_speed, "max_estimated_speed": self.max_estimated_speed, "velocity_angle": self.velocity_angle, From aa51122879f36b2b3052497c88208d874d39f57a Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 8 Dec 2024 17:31:25 -0600 Subject: [PATCH 12/17] unit_system in config --- frigate/config/ui.py | 10 +++++++++- web/src/types/frigateConfig.ts | 1 + 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/frigate/config/ui.py b/frigate/config/ui.py index a562edf61..2f66aeed3 100644 --- a/frigate/config/ui.py +++ b/frigate/config/ui.py @@ -5,7 +5,7 @@ from pydantic import Field from .base import FrigateBaseModel -__all__ = ["TimeFormatEnum", "DateTimeStyleEnum", "UIConfig"] +__all__ = ["TimeFormatEnum", "DateTimeStyleEnum", "UnitSystemEnum", "UIConfig"] class TimeFormatEnum(str, Enum): @@ -21,6 +21,11 @@ class DateTimeStyleEnum(str, Enum): short = "short" +class UnitSystemEnum(str, Enum): + imperial = "imperial" + metric = "metric" + + class UIConfig(FrigateBaseModel): timezone: Optional[str] = Field(default=None, title="Override UI timezone.") time_format: TimeFormatEnum = Field( @@ -35,3 +40,6 @@ class UIConfig(FrigateBaseModel): strftime_fmt: Optional[str] = Field( default=None, title="Override date and time format using strftime syntax." ) + unit_system: UnitSystemEnum = Field( + default=UnitSystemEnum.metric, title="The unit system to use for measurements." + ) diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index 45bb39475..1220412a8 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -8,6 +8,7 @@ export interface UiConfig { strftime_fmt?: string; dashboard: boolean; order: number; + unit_system?: "metric" | "imperial"; } export interface BirdseyeConfig { From b97fe96c887a4c17d707424295bc64602b5ed570 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 9 Dec 2024 08:05:43 -0600 Subject: [PATCH 13/17] ability to define unit system in config --- frigate/object_processing.py | 1 + frigate/track/tracked_object.py | 15 +++++++++++---- frigate/util/velocity.py | 14 +------------- 3 files changed, 13 insertions(+), 17 deletions(-) diff --git a/frigate/object_processing.py b/frigate/object_processing.py index a718ef2c7..9c9a1bbf5 100644 --- a/frigate/object_processing.py +++ b/frigate/object_processing.py @@ -261,6 +261,7 @@ class CameraState: new_obj = tracked_objects[id] = TrackedObject( self.config.model, self.camera_config, + self.config.ui, self.frame_cache, current_detections[id], ) diff --git a/frigate/track/tracked_object.py b/frigate/track/tracked_object.py index a85912b8a..0c05541a2 100644 --- a/frigate/track/tracked_object.py +++ b/frigate/track/tracked_object.py @@ -12,6 +12,7 @@ import numpy as np from frigate.config import ( CameraConfig, ModelConfig, + UIConfig, ) from frigate.util.image import ( area, @@ -31,6 +32,7 @@ class TrackedObject: self, model_config: ModelConfig, camera_config: CameraConfig, + ui_config: UIConfig, frame_cache, obj_data: dict[str, any], ): @@ -42,6 +44,7 @@ class TrackedObject: self.colormap = model_config.colormap self.logos = model_config.all_attribute_logos self.camera_config = camera_config + self.ui_config = ui_config self.frame_cache = frame_cache self.zone_presence: dict[str, int] = {} self.zone_loitering: dict[str, int] = {} @@ -159,7 +162,7 @@ class TrackedObject: # update speed if zone.distances and name in self.entered_zones: - self.estimated_speed, self.velocity_angle = ( + speed_magnitude, self.velocity_angle = ( calculate_real_world_speed( zone.contour, zone.distances, @@ -170,10 +173,14 @@ class TrackedObject: if self.active else 0 ) + if self.ui_config.unit_system == "metric": + # Convert m/s to km/h + self.estimated_speed = speed_magnitude * 3.6 + elif self.ui_config.unit_system == "imperial": + # Convert ft/s to mph + self.estimated_speed = speed_magnitude * 0.681818 logger.debug( - f"Camera: {self.camera_config.name}, zone: {name}, tracked object ID: {self.obj_data['id']}, \ - pixel velocity: {str(tuple(np.round(self.obj_data["estimate_velocity"]).flatten().astype(int)))} \ - estimated speed: {self.estimated_speed:.1f}" + f"Camera: {self.camera_config.name}, zone: {name}, tracked object ID: {self.obj_data['id']}, pixel velocity: {str(tuple(np.round(self.obj_data['estimate_velocity']).flatten().astype(int)))} estimated speed: {self.estimated_speed:.1f}" ) if self.estimated_speed > self.max_estimated_speed: diff --git a/frigate/util/velocity.py b/frigate/util/velocity.py index 276c9815c..3d6b0a91a 100644 --- a/frigate/util/velocity.py +++ b/frigate/util/velocity.py @@ -2,9 +2,6 @@ import math import numpy as np -unit_system = "imperial" -magnitude = "mph" - def create_ground_plane(zone_points, distances): """ @@ -68,7 +65,7 @@ def calculate_real_world_speed( :param velocity_pixels: List of tuples representing velocity in pixels/frame :param position: Current position of the object (x, y) in pixels :param camera_fps: Frames per second of the camera - :return: speed in the specified unit system (m/s for metric, ft/s for imperial) and velocity direction + :return: speed and velocity angle direction """ ground_plane = create_ground_plane(zone_contour, distances) @@ -90,13 +87,4 @@ def calculate_real_world_speed( if angle < 0: angle += 360 - if unit_system == "metric": - if magnitude == "kmh": - # Convert m/s to km/h - speed_magnitude *= 3.6 - elif unit_system == "imperial": - if magnitude == "mph": - # Convert ft/s to mph - speed_magnitude *= 0.681818 - return speed_magnitude, angle From 227eec49a38cc6bbb33361b3ef0ede2b18d1c420 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 9 Dec 2024 08:05:58 -0600 Subject: [PATCH 14/17] save max speed to db --- frigate/events/maintainer.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frigate/events/maintainer.py b/frigate/events/maintainer.py index 3a4209ec3..f9bd67288 100644 --- a/frigate/events/maintainer.py +++ b/frigate/events/maintainer.py @@ -25,6 +25,7 @@ def should_update_db(prev_event: Event, current_event: Event) -> bool: or prev_event["entered_zones"] != current_event["entered_zones"] or prev_event["thumbnail"] != current_event["thumbnail"] or prev_event["end_time"] != current_event["end_time"] + or prev_event["max_estimated_speed"] != current_event["max_estimated_speed"] ): return True return False @@ -209,6 +210,7 @@ class EventProcessor(threading.Thread): "score": score, "top_score": event_data["top_score"], "attributes": attributes, + "max_estimated_speed": event_data["max_estimated_speed"], "type": "object", }, } From cfc4a5367ec2d8a3b0ab10097d6a7fbff6d08fd7 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 9 Dec 2024 08:06:06 -0600 Subject: [PATCH 15/17] frontend --- .../overlay/detail/SearchDetailDialog.tsx | 21 +++++++++++++++++++ web/src/types/search.ts | 1 + 2 files changed, 22 insertions(+) diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index b0eeac98d..dc03eef5a 100644 --- a/web/src/components/overlay/detail/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -316,6 +316,18 @@ function ObjectDetailsTab({ } }, [search]); + const maxEstimatedSpeed = useMemo(() => { + if (!search || !search.data?.max_estimated_speed) { + return undefined; + } + + if (search.data?.max_estimated_speed != 0) { + return search.data?.max_estimated_speed.toFixed(1); + } else { + return undefined; + } + }, [search]); + const updateDescription = useCallback(() => { if (!search) { return; @@ -427,6 +439,15 @@ function ObjectDetailsTab({ {score}%{subLabelScore && ` (${subLabelScore}%)`} + {maxEstimatedSpeed && ( +
+
Max Estimated Speed
+
+ {maxEstimatedSpeed}{" "} + {config?.ui.unit_system == "imperial" ? "mph" : "kph"} +
+
+ )}
Camera
diff --git a/web/src/types/search.ts b/web/src/types/search.ts index 1d8de1611..223370e9a 100644 --- a/web/src/types/search.ts +++ b/web/src/types/search.ts @@ -55,6 +55,7 @@ export type SearchResult = { ratio: number; type: "object" | "audio" | "manual"; description?: string; + max_estimated_speed: number; }; }; From 60550d9cde019d64179c15cb8bde5a8d236b6056 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 9 Dec 2024 10:23:54 -0600 Subject: [PATCH 16/17] docs --- docs/docs/configuration/reference.md | 6 +++++ docs/docs/configuration/zones.md | 38 ++++++++++++++++++++++----- docs/docs/integrations/mqtt.md | 10 +++++-- docs/static/img/ground-plane.jpg | Bin 0 -> 54107 bytes 4 files changed, 46 insertions(+), 8 deletions(-) create mode 100644 docs/static/img/ground-plane.jpg diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index bb7ae49a3..1737b5902 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/reference.md @@ -640,6 +640,9 @@ cameras: # Required: List of x,y coordinates to define the polygon of the zone. # NOTE: Presence in a zone is evaluated only based on the bottom center of the objects bounding box. coordinates: 0.284,0.997,0.389,0.869,0.410,0.745 + # Optional: The real-world distances of a 4-sided zone used for zones with speed estimation enabled (default: none) + # List distances in order of the zone points coordinates and use the unit system defined in the ui config + distances: 10,15,12,11 # Optional: Number of consecutive frames required for object to be considered present in the zone (default: shown below). inertia: 3 # Optional: Number of seconds that an object must loiter to be considered in the zone (default: shown below) @@ -785,6 +788,9 @@ ui: # https://www.gnu.org/software/libc/manual/html_node/Formatting-Calendar-Time.html # possible values are shown above (default: not set) strftime_fmt: "%Y/%m/%d %H:%M" + # Optional: Set the unit system to either "imperial" or "metric" (default: metric) + # Used in the UI and in MQTT topics + unit_system: metric # Optional: Telemetry configuration telemetry: diff --git a/docs/docs/configuration/zones.md b/docs/docs/configuration/zones.md index aef6b0a5b..a111d0439 100644 --- a/docs/docs/configuration/zones.md +++ b/docs/docs/configuration/zones.md @@ -122,16 +122,42 @@ cameras: - car ``` -### Loitering Time +### Speed Estimation -Zones support a `loitering_time` configuration which can be used to only consider an object as part of a zone if they loiter in the zone for the specified number of seconds. This can be used, for example, to create alerts for cars that stop on the street but not cars that just drive past your camera. +Frigate can be configured to estimate the speed of objects moving through a zone. This works by combining data from Frigate's object tracker and "real world" distance measurements of the edges of the zone. The recommended use case for this feature is to track the speed of vehicles on a road. + +Your zone must be defined with exactly 4 points and should be aligned to the ground where objects are moving. + +![Ground plane 4-point zone](/img/ground-plane.jpg) + +Speed estimation requires a minimum number of frames for your object to be tracked before a valid estimate can be calculated, so create your zone away from the edges of the frame for the best results. _Your zone should not take up the full frame._ Once an object enters a speed estimation zone, its speed will continue to be tracked, even after it leaves the zone. + +Accurate real-world distance measurements are required to estimate speeds. These distances can be specified in your zone config through the `distances` field. ```yaml cameras: name_of_your_camera: zones: - front_yard: - loitering_time: 5 # unit is in seconds - objects: - - person + street: + coordinates: 0.033,0.306,0.324,0.138,0.439,0.185,0.042,0.428 + distances: 10,12,11,13.5 ``` + +Each number in the `distance` field represents the real-world distance between the points in the `coordinates` list. So in the example above, the distance between the first two points ([0.033,0.306] and [0.324,0.138]) is 10. The distance between the second and third set of points ([0.324,0.138] and [0.439,0.185]) is 12, and so on. The fastest and most accurate way to configure this is through the Zone Editor in the Frigate UI. + +The `distance` values are measured in meters or feet, depending on how `unit_system` is configured in your `ui` config: + +```yaml +ui: + # can be "metric" or "imperial", default is metric + unit_system: metric +``` + +The maximum speed during the object's lifetime is saved in Frigate's database and can be seen in the UI in the Tracked Object Details pane in Explore. Current estimated speed can also be seen on the debug view as the third value in the object label. Current estimated speed, max estimated speed, and velocity angle (the angle of the direction the object is moving relative to the frame) of tracked objects is also sent through the `events` MQTT topic in the `data` field. See the [MQTT docs](../integrations/mqtt.md#frigateevents). + +#### Best practices and caveats + +- Speed estimation works best with a straight road or path when your object travels in a straight line across that path. If your object makes turns, speed estimation may not be accurate. +- Create a zone where the bottom center of your object's bounding box travels directly through it. +- The more accurate your real-world dimensions can be measured, the more accurate speed estimation will be. However, due to the way Frigate's tracking algorithm works, you may need to tweak the real-world distance values so that estimated speeds better match real-world speeds. +- The speeds are only an _estimation_ and are highly dependent on camera position, zone points, and real-world measurements. This feature should not be used for law enforcement. diff --git a/docs/docs/integrations/mqtt.md b/docs/docs/integrations/mqtt.md index 194821cbd..e2fcbcd67 100644 --- a/docs/docs/integrations/mqtt.md +++ b/docs/docs/integrations/mqtt.md @@ -52,7 +52,10 @@ Message published for each changed tracked object. The first message is publishe "attributes": { "face": 0.64 }, // attributes with top score that have been identified on the object at any point - "current_attributes": [] // detailed data about the current attributes in this frame + "current_attributes": [], // detailed data about the current attributes in this frame + "estimated_speed": 0.71, // current estimated speed (mph or kph) for objects moving through zones with speed estimation enabled + "max_estimated_speed": 1.2, // max estimated speed (mph or kph) for objects moving through zones with speed estimation enabled + "velocity_angle": 180 // direction of travel relative to the frame for objects moving through zones with speed estimation enabled }, "after": { "id": "1607123955.475377-mxklsc", @@ -89,7 +92,10 @@ Message published for each changed tracked object. The first message is publishe "box": [442, 506, 534, 524], "score": 0.86 } - ] + ], + "estimated_speed": 0.77, // current estimated speed (mph or kph) for objects moving through zones with speed estimation enabled + "max_estimated_speed": 1.2, // max estimated speed (mph or kph) for objects moving through zones with speed estimation enabled + "velocity_angle": 180 // direction of travel relative to the frame for objects moving through zones with speed estimation enabled } } ``` diff --git a/docs/static/img/ground-plane.jpg b/docs/static/img/ground-plane.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2e7ed2e15a208db3acba1052dafef1b99ed7a5f6 GIT binary patch literal 54107 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 Date: Mon, 9 Dec 2024 10:27:11 -0600 Subject: [PATCH 17/17] clarify docs --- docs/docs/configuration/reference.md | 2 +- docs/docs/configuration/zones.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index 1737b5902..bf7729844 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/reference.md @@ -639,7 +639,7 @@ cameras: front_steps: # Required: List of x,y coordinates to define the polygon of the zone. # NOTE: Presence in a zone is evaluated only based on the bottom center of the objects bounding box. - coordinates: 0.284,0.997,0.389,0.869,0.410,0.745 + coordinates: 0.033,0.306,0.324,0.138,0.439,0.185,0.042,0.428 # Optional: The real-world distances of a 4-sided zone used for zones with speed estimation enabled (default: none) # List distances in order of the zone points coordinates and use the unit system defined in the ui config distances: 10,15,12,11 diff --git a/docs/docs/configuration/zones.md b/docs/docs/configuration/zones.md index a111d0439..0edfea299 100644 --- a/docs/docs/configuration/zones.md +++ b/docs/docs/configuration/zones.md @@ -153,7 +153,7 @@ ui: unit_system: metric ``` -The maximum speed during the object's lifetime is saved in Frigate's database and can be seen in the UI in the Tracked Object Details pane in Explore. Current estimated speed can also be seen on the debug view as the third value in the object label. Current estimated speed, max estimated speed, and velocity angle (the angle of the direction the object is moving relative to the frame) of tracked objects is also sent through the `events` MQTT topic in the `data` field. See the [MQTT docs](../integrations/mqtt.md#frigateevents). +The maximum speed during the object's lifetime is saved in Frigate's database and can be seen in the UI in the Tracked Object Details pane in Explore. Current estimated speed can also be seen on the debug view as the third value in the object label. Current estimated speed, max estimated speed, and velocity angle (the angle of the direction the object is moving relative to the frame) of tracked objects is also sent through the `events` MQTT topic. See the [MQTT docs](../integrations/mqtt.md#frigateevents). #### Best practices and caveats