diff --git a/frigate/config/camera/zone.py b/frigate/config/camera/zone.py index f2e92f3a8..3e69240d5 100644 --- a/frigate/config/camera/zone.py +++ b/frigate/config/camera/zone.py @@ -1,13 +1,16 @@ # this uses the base model because the color is an extra attribute +import logging from typing import Optional, Union import numpy as np -from pydantic import BaseModel, Field, PrivateAttr, field_validator +from pydantic import BaseModel, Field, PrivateAttr, field_validator, model_validator from .objects import FilterConfig __all__ = ["ZoneConfig"] +logger = logging.getLogger(__name__) + class ZoneConfig(BaseModel): filters: dict[str, FilterConfig] = Field( @@ -30,6 +33,11 @@ class ZoneConfig(BaseModel): ge=0, title="Number of seconds that an object must loiter to be considered in the zone.", ) + speed_threshold: Optional[float] = Field( + default=None, + ge=0.1, + title="Minimum speed value for an object to be considered in the zone.", + ) objects: Union[str, list[str]] = Field( default_factory=list, title="List of objects that can trigger the zone.", @@ -71,6 +79,16 @@ class ZoneConfig(BaseModel): return distances + @model_validator(mode="after") + def check_loitering_time_constraints(self): + if self.loitering_time > 0 and ( + self.speed_threshold is not None or len(self.distances) > 0 + ): + logger.warning( + "loitering_time should not be set on a zone if speed_threshold or distances is set." + ) + return self + def __init__(self, **config): super().__init__(**config) @@ -107,7 +125,7 @@ class ZoneConfig(BaseModel): if explicit: self.coordinates = ",".join( [ - f'{round(int(p.split(",")[0]) / frame_shape[1], 3)},{round(int(p.split(",")[1]) / frame_shape[0], 3)}' + f"{round(int(p.split(',')[0]) / frame_shape[1], 3)},{round(int(p.split(',')[1]) / frame_shape[0], 3)}" for p in coordinates ] ) diff --git a/frigate/track/tracked_object.py b/frigate/track/tracked_object.py index e64748623..ea1aeedcb 100644 --- a/frigate/track/tracked_object.py +++ b/frigate/track/tracked_object.py @@ -146,6 +146,7 @@ class TrackedObject: current_zones = [] bottom_center = (obj_data["centroid"][0], obj_data["box"][3]) in_loitering_zone = False + in_speed_zone = False # check each zone for name, zone in self.camera_config.zones.items(): @@ -154,12 +155,66 @@ class TrackedObject: continue contour = zone.contour zone_score = self.zone_presence.get(name, 0) + 1 + # check if the object is in the zone if cv2.pointPolygonTest(contour, bottom_center, False) >= 0: # if the object passed the filters once, dont apply again if name in self.current_zones or not zone_filtered(self, zone.filters): - # an object is only considered present in a zone if it has a zone inertia of 3+ + # Calculate speed first if this is a speed zone + if ( + zone.distances + and obj_data["frame_time"] == current_frame_time + and self.active + ): + 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.ui_config.unit_system == "metric": + self.current_estimated_speed = ( + speed_magnitude * 3.6 + ) # m/s to km/h + else: + self.current_estimated_speed = ( + speed_magnitude * 0.681818 + ) # ft/s to mph + + self.speed_history.append(self.current_estimated_speed) + if len(self.speed_history) > 10: + self.speed_history = self.speed_history[-10:] + + self.average_estimated_speed = sum(self.speed_history) / len( + self.speed_history + ) + + # we've exceeded the speed threshold on the zone + # or we don't have a speed threshold set + if ( + zone.speed_threshold is None + or self.average_estimated_speed > zone.speed_threshold + ): + in_speed_zone = True + + logger.debug( + f"Camera: {self.camera_config.name}, tracked object ID: {self.obj_data['id']}, " + f"zone: {name}, pixel velocity: {str(tuple(np.round(self.obj_data['estimate_velocity']).flatten().astype(int)))}, " + f"speed magnitude: {speed_magnitude}, velocity angle: {self.velocity_angle}, " + f"estimated speed: {self.current_estimated_speed:.1f}, " + f"average speed: {self.average_estimated_speed:.1f}, " + f"length: {len(self.speed_history)}" + ) + + # Check zone entry conditions - for speed zones, require both inertia and speed if zone_score >= zone.inertia: + if zone.distances and not in_speed_zone: + continue # Skip zone entry for speed zones until speed threshold met + # if the zone has loitering time, update loitering status if zone.loitering_time > 0: in_loitering_zone = True @@ -184,44 +239,8 @@ class TrackedObject: if 0 < zone_score < zone.inertia: self.zone_presence[name] = zone_score - 1 - # update speed - if ( - zone.distances - and name in self.current_zones - and obj_data["frame_time"] == current_frame_time - and self.active - ): - 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, 0) - ) - if self.ui_config.unit_system == "metric": - # Convert m/s to km/h - self.current_estimated_speed = speed_magnitude * 3.6 - elif self.ui_config.unit_system == "imperial": - # Convert ft/s to mph - self.current_estimated_speed = speed_magnitude * 0.681818 - - # only keep the last 10 speeds - if len(self.speed_history) > 10: - self.speed_history = self.speed_history[-10:] - - self.speed_history.append(self.current_estimated_speed) - self.average_estimated_speed = sum(self.speed_history) / len( - self.speed_history - ) - logger.debug( - f"Camera: {self.camera_config.name}, tracked object ID: {self.obj_data['id']}, zone: {name}, pixel velocity: {str(tuple(np.round(self.obj_data['estimate_velocity']).flatten().astype(int)))}, speed magnitude: {speed_magnitude}, velocity angle: {self.velocity_angle}, estimated speed: {self.current_estimated_speed:.1f}, average speed: {self.average_estimated_speed:.1f}, length: {len(self.speed_history)}" - ) - else: - # ensure we do not display an estimate if the object is not currently in a speed tracking zone + # Reset speed if not in speed zone + if zone.distances and name not in current_zones: self.current_estimated_speed = 0 # update loitering status @@ -392,7 +411,7 @@ 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.thumbnail_data['current_estimated_speed']:.1f}" if self.thumbnail_data["current_estimated_speed"] != 0 diff --git a/web/src/components/settings/ZoneEditPane.tsx b/web/src/components/settings/ZoneEditPane.tsx index 1ec7a1244..9caf04273 100644 --- a/web/src/components/settings/ZoneEditPane.tsx +++ b/web/src/components/settings/ZoneEditPane.tsx @@ -181,6 +181,13 @@ export default function ZoneEditPane({ }) .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) => { @@ -193,6 +200,21 @@ export default function ZoneEditPane({ 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>({ @@ -215,6 +237,10 @@ export default function ZoneEditPane({ lineB, lineC, lineD, + speed_threshold: + polygon?.camera && + polygon?.name && + config?.cameras[polygon.camera]?.zones[polygon.name]?.speed_threshold, }, }); @@ -243,6 +269,7 @@ export default function ZoneEditPane({ lineB, lineC, lineD, + speed_threshold, }: ZoneFormValuesType, // values submitted via the form objects: string[], ) => { @@ -349,9 +376,22 @@ export default function ZoneEditPane({ } } + 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}${distancesQuery}${objectQueries}${alertQueries}${detectionQueries}`, + `config/set?cameras.${polygon?.camera}.zones.${zoneName}.coordinates=${coordinates}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${alertQueries}${detectionQueries}`, { requires_restart: 0 }, ) .then((res) => { @@ -573,6 +613,14 @@ export default function ZoneEditPane({ ); 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); }} /> @@ -607,6 +655,7 @@ export default function ZoneEditPane({ setActiveLine(1)} onBlur={() => setActiveLine(undefined)} @@ -629,6 +678,7 @@ export default function ZoneEditPane({ setActiveLine(2)} onBlur={() => setActiveLine(undefined)} @@ -651,6 +701,7 @@ export default function ZoneEditPane({ setActiveLine(3)} onBlur={() => setActiveLine(undefined)} @@ -673,6 +724,7 @@ export default function ZoneEditPane({ setActiveLine(4)} onBlur={() => setActiveLine(undefined)} @@ -681,6 +733,31 @@ export default function ZoneEditPane({ )} /> + + + ( + + + Speed Threshold ( + {config?.ui.unit_system == "imperial" ? "mph" : "kph"}) + + + + + + Specifies a minimum speed for objects to be considered + in this zone. + + + + )} + /> )} diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts index d6d9f84f7..9c1748ce0 100644 --- a/web/src/types/canvas.ts +++ b/web/src/types/canvas.ts @@ -24,6 +24,7 @@ export type ZoneFormValuesType = { lineB: number; lineC: number; lineD: number; + speed_threshold: number; }; export type ObjectMaskFormValuesType = { diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index 1220412a8..82dc21d04 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -219,6 +219,7 @@ export interface CameraConfig { filters: Record; inertia: number; loitering_time: number; + speed_threshold: number; objects: string[]; color: number[]; };