diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index bb7ae49a3c..bf7729844e 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/reference.md @@ -639,7 +639,10 @@ 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 # 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 aef6b0a5b2..0edfea299a 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. 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 194821cbdc..e2fcbcd67e 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 0000000000..2e7ed2e15a Binary files /dev/null and b/docs/static/img/ground-plane.jpg differ diff --git a/frigate/config/camera/zone.py b/frigate/config/camera/zone.py index 65b34a049e..f2e92f3a83 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 @@ def validate_objects(cls, v): 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) diff --git a/frigate/config/ui.py b/frigate/config/ui.py index a562edf614..2f66aeed31 100644 --- a/frigate/config/ui.py +++ b/frigate/config/ui.py @@ -5,7 +5,7 @@ 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/frigate/events/maintainer.py b/frigate/events/maintainer.py index e2b9245d6e..0dcfad509b 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 @@ def handle_object_detection( "score": score, "top_score": event_data["top_score"], "attributes": attributes, + "max_estimated_speed": event_data["max_estimated_speed"], "type": "object", "max_severity": event_data.get("max_severity"), }, diff --git a/frigate/object_processing.py b/frigate/object_processing.py index b5196e6862..54a61c2d41 100644 --- a/frigate/object_processing.py +++ b/frigate/object_processing.py @@ -162,7 +162,12 @@ def get_current_frame(self, draw_options={}): 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, ) @@ -256,6 +261,7 @@ def update( 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 3280965da3..e3bc62c5be 100644 --- a/frigate/track/tracked_object.py +++ b/frigate/track/tracked_object.py @@ -12,6 +12,7 @@ from frigate.config import ( CameraConfig, ModelConfig, + UIConfig, ) from frigate.review.types import SeverityEnum from frigate.util.image import ( @@ -22,6 +23,7 @@ is_better_thumbnail, ) from frigate.util.object import box_inside +from frigate.util.velocity import calculate_real_world_speed logger = logging.getLogger(__name__) @@ -31,6 +33,7 @@ def __init__( self, model_config: ModelConfig, camera_config: CameraConfig, + ui_config: UIConfig, frame_cache, obj_data: dict[str, any], ): @@ -42,6 +45,7 @@ def __init__( 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] = {} @@ -58,6 +62,9 @@ def __init__( 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() @property @@ -129,6 +136,7 @@ def update(self, current_frame_time: float, obj_data, has_valid_frame: bool): "region": obj_data["region"], "score": obj_data["score"], "attributes": obj_data["attributes"], + "estimated_speed": self.estimated_speed, } thumb_update = True @@ -174,6 +182,32 @@ def update(self, current_frame_time: float, obj_data, has_valid_frame: bool): if 0 < zone_score < zone.inertia: self.zone_presence[name] = zone_score - 1 + # update speed + if zone.distances and name in self.entered_zones: + speed_magnitude, 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 + ) + 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}" + ) + + if self.estimated_speed > self.max_estimated_speed: + self.max_estimated_speed = self.estimated_speed + # update loitering status self.pending_loitering = in_loitering_zone @@ -255,6 +289,9 @@ def to_dict(self, include_thumbnail: bool = False): "current_attributes": self.obj_data["attributes"], "pending_loitering": self.pending_loitering, "max_severity": self.max_severity, + "estimated_speed": self.estimated_speed, + "max_estimated_speed": self.max_estimated_speed, + "velocity_angle": self.velocity_angle, } if include_thumbnail: @@ -339,7 +376,8 @@ def get_jpg_bytes( 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, ) diff --git a/frigate/util/velocity.py b/frigate/util/velocity.py new file mode 100644 index 0000000000..3d6b0a91ae --- /dev/null +++ b/frigate/util/velocity.py @@ -0,0 +1,90 @@ +import math + +import numpy as np + + +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 in circular order + [[x1, y1], [x2, y2], [x3, y3], [x4, y4]] + :param distances: Real-world dimensions ordered by A, B, C, D + :return: Function that calculates real-world distance per pixel at any coordinate + """ + A, B, C, D = zone_points + + # Calculate pixel lengths of each side + 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)) + + 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): + """ + 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 - A[0]) / (B[0] - A[0]) + y_norm = (y - A[1]) / (D[1] - A[1]) + + # Interpolate scales horizontally and vertically + 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 (vertical_scale + horizontal_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 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_fps: Frames per second of the camera + :return: speed and velocity angle 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 + + return speed_magnitude, angle diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index b0eeac98d1..dc03eef5a3 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/components/settings/PolygonCanvas.tsx b/web/src/components/settings/PolygonCanvas.tsx index e6851b63c9..d2a0a46b53 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 966aad2ca1..1ae3d46016 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 54799db727..ec60086c58 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"); @@ -80,69 +82,122 @@ 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) || []; - - 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.", + const [lineA, lineB, lineC, lineD] = 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) + : [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("")), + }) + .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), - mode: "onChange", + mode: "onBlur", defaultValues: { name: polygon?.name ?? "", inertia: @@ -155,9 +210,27 @@ 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, }, }); + 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 +238,11 @@ export default function ZoneEditPane({ inertia, loitering_time, objects: form_objects, + speedEstimation, + lineA, + lineB, + lineC, + lineD, }: ZoneFormValuesType, // values submitted via the form objects: string[], ) => { @@ -261,9 +339,17 @@ export default function ZoneEditPane({ loiteringTimeQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.loitering_time=${loitering_time}`; } + let distancesQuery = ""; + const distances = [lineA, lineB, lineC, lineD].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 +542,122 @@ 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 && ( + <> + ( + + Line A distance + + setActiveLine(1)} + onBlur={() => setActiveLine(undefined)} + /> + + + )} + /> + ( + + Line B distance + + setActiveLine(2)} + onBlur={() => setActiveLine(undefined)} + /> + + + )} + /> + ( + + Line C distance + + setActiveLine(3)} + onBlur={() => setActiveLine(undefined)} + /> + + + )} + /> + ( + + Line D distance + + setActiveLine(4)} + onBlur={() => setActiveLine(undefined)} + /> + + + )} + /> + + )} + { updateLabelFilter(currentLabels); - }, [currentLabels, updateLabelFilter]); + // we know that these deps are correct + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentLabels]); return ( <> diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts index f623e8b48d..d6d9f84f7e 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[]; }; @@ -18,6 +19,11 @@ export type ZoneFormValuesType = { loitering_time: number; isFinished: boolean; objects: string[]; + speedEstimation: boolean; + lineA: number; + lineB: number; + lineC: number; + lineD: number; }; export type ObjectMaskFormValuesType = { diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index 5c5971fc08..1220412a88 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 { @@ -214,6 +215,7 @@ export interface CameraConfig { zones: { [zoneName: string]: { coordinates: string; + distances: string[]; filters: Record; inertia: number; loitering_time: number; diff --git a/web/src/types/search.ts b/web/src/types/search.ts index 1d8de16116..223370e9a2 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; }; }; diff --git a/web/src/views/settings/MasksAndZonesView.tsx b/web/src/views/settings/MasksAndZonesView.tsx index ab2646b5f5..5c74a121bb 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} /> ) : (