Skip to content

Commit 22e3ed4

Browse files
authored
Merge pull request #35 from hawkeye217/object-speed
features
2 parents 0b9c4c1 + edb79f3 commit 22e3ed4

File tree

18 files changed

+602
-74
lines changed

18 files changed

+602
-74
lines changed

docs/docs/configuration/reference.md

+7-1
Original file line numberDiff line numberDiff line change
@@ -639,7 +639,10 @@ cameras:
639639
front_steps:
640640
# Required: List of x,y coordinates to define the polygon of the zone.
641641
# NOTE: Presence in a zone is evaluated only based on the bottom center of the objects bounding box.
642-
coordinates: 0.284,0.997,0.389,0.869,0.410,0.745
642+
coordinates: 0.033,0.306,0.324,0.138,0.439,0.185,0.042,0.428
643+
# Optional: The real-world distances of a 4-sided zone used for zones with speed estimation enabled (default: none)
644+
# List distances in order of the zone points coordinates and use the unit system defined in the ui config
645+
distances: 10,15,12,11
643646
# Optional: Number of consecutive frames required for object to be considered present in the zone (default: shown below).
644647
inertia: 3
645648
# Optional: Number of seconds that an object must loiter to be considered in the zone (default: shown below)
@@ -785,6 +788,9 @@ ui:
785788
# https://www.gnu.org/software/libc/manual/html_node/Formatting-Calendar-Time.html
786789
# possible values are shown above (default: not set)
787790
strftime_fmt: "%Y/%m/%d %H:%M"
791+
# Optional: Set the unit system to either "imperial" or "metric" (default: metric)
792+
# Used in the UI and in MQTT topics
793+
unit_system: metric
788794

789795
# Optional: Telemetry configuration
790796
telemetry:

docs/docs/configuration/zones.md

+32-6
Original file line numberDiff line numberDiff line change
@@ -122,16 +122,42 @@ cameras:
122122
- car
123123
```
124124

125-
### Loitering Time
125+
### Speed Estimation
126126

127-
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.
127+
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.
128+
129+
Your zone must be defined with exactly 4 points and should be aligned to the ground where objects are moving.
130+
131+
![Ground plane 4-point zone](/img/ground-plane.jpg)
132+
133+
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.
134+
135+
Accurate real-world distance measurements are required to estimate speeds. These distances can be specified in your zone config through the `distances` field.
128136

129137
```yaml
130138
cameras:
131139
name_of_your_camera:
132140
zones:
133-
front_yard:
134-
loitering_time: 5 # unit is in seconds
135-
objects:
136-
- person
141+
street:
142+
coordinates: 0.033,0.306,0.324,0.138,0.439,0.185,0.042,0.428
143+
distances: 10,12,11,13.5
137144
```
145+
146+
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.
147+
148+
The `distance` values are measured in meters or feet, depending on how `unit_system` is configured in your `ui` config:
149+
150+
```yaml
151+
ui:
152+
# can be "metric" or "imperial", default is metric
153+
unit_system: metric
154+
```
155+
156+
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).
157+
158+
#### Best practices and caveats
159+
160+
- 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.
161+
- Create a zone where the bottom center of your object's bounding box travels directly through it.
162+
- 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.
163+
- 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.

docs/docs/integrations/mqtt.md

+8-2
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,10 @@ Message published for each changed tracked object. The first message is publishe
5252
"attributes": {
5353
"face": 0.64
5454
}, // attributes with top score that have been identified on the object at any point
55-
"current_attributes": [] // detailed data about the current attributes in this frame
55+
"current_attributes": [], // detailed data about the current attributes in this frame
56+
"estimated_speed": 0.71, // current estimated speed (mph or kph) for objects moving through zones with speed estimation enabled
57+
"max_estimated_speed": 1.2, // max estimated speed (mph or kph) for objects moving through zones with speed estimation enabled
58+
"velocity_angle": 180 // direction of travel relative to the frame for objects moving through zones with speed estimation enabled
5659
},
5760
"after": {
5861
"id": "1607123955.475377-mxklsc",
@@ -89,7 +92,10 @@ Message published for each changed tracked object. The first message is publishe
8992
"box": [442, 506, 534, 524],
9093
"score": 0.86
9194
}
92-
]
95+
],
96+
"estimated_speed": 0.77, // current estimated speed (mph or kph) for objects moving through zones with speed estimation enabled
97+
"max_estimated_speed": 1.2, // max estimated speed (mph or kph) for objects moving through zones with speed estimation enabled
98+
"velocity_angle": 180 // direction of travel relative to the frame for objects moving through zones with speed estimation enabled
9399
}
94100
}
95101
```

docs/static/img/ground-plane.jpg

52.8 KB
Loading

frigate/config/camera/zone.py

+22
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ class ZoneConfig(BaseModel):
1616
coordinates: Union[str, list[str]] = Field(
1717
title="Coordinates polygon for the defined zone."
1818
)
19+
distances: Optional[Union[str, list[str]]] = Field(
20+
default_factory=list,
21+
title="Real-world distances for the sides of quadrilateral for the defined zone.",
22+
)
1923
inertia: int = Field(
2024
default=3,
2125
title="Number of consecutive frames required for object to be considered present in the zone.",
@@ -49,6 +53,24 @@ def validate_objects(cls, v):
4953

5054
return v
5155

56+
@field_validator("distances", mode="before")
57+
@classmethod
58+
def validate_distances(cls, v):
59+
if v is None:
60+
return None
61+
62+
if isinstance(v, str):
63+
distances = list(map(str, map(float, v.split(","))))
64+
elif isinstance(v, list):
65+
distances = [str(float(val)) for val in v]
66+
else:
67+
raise ValueError("Invalid type for distances")
68+
69+
if len(distances) != 4:
70+
raise ValueError("distances must have exactly 4 values")
71+
72+
return distances
73+
5274
def __init__(self, **config):
5375
super().__init__(**config)
5476

frigate/config/ui.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from .base import FrigateBaseModel
77

8-
__all__ = ["TimeFormatEnum", "DateTimeStyleEnum", "UIConfig"]
8+
__all__ = ["TimeFormatEnum", "DateTimeStyleEnum", "UnitSystemEnum", "UIConfig"]
99

1010

1111
class TimeFormatEnum(str, Enum):
@@ -21,6 +21,11 @@ class DateTimeStyleEnum(str, Enum):
2121
short = "short"
2222

2323

24+
class UnitSystemEnum(str, Enum):
25+
imperial = "imperial"
26+
metric = "metric"
27+
28+
2429
class UIConfig(FrigateBaseModel):
2530
timezone: Optional[str] = Field(default=None, title="Override UI timezone.")
2631
time_format: TimeFormatEnum = Field(
@@ -35,3 +40,6 @@ class UIConfig(FrigateBaseModel):
3540
strftime_fmt: Optional[str] = Field(
3641
default=None, title="Override date and time format using strftime syntax."
3742
)
43+
unit_system: UnitSystemEnum = Field(
44+
default=UnitSystemEnum.metric, title="The unit system to use for measurements."
45+
)

frigate/events/maintainer.py

+2
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ def should_update_db(prev_event: Event, current_event: Event) -> bool:
2525
or prev_event["entered_zones"] != current_event["entered_zones"]
2626
or prev_event["thumbnail"] != current_event["thumbnail"]
2727
or prev_event["end_time"] != current_event["end_time"]
28+
or prev_event["max_estimated_speed"] != current_event["max_estimated_speed"]
2829
):
2930
return True
3031
return False
@@ -209,6 +210,7 @@ def handle_object_detection(
209210
"score": score,
210211
"top_score": event_data["top_score"],
211212
"attributes": attributes,
213+
"max_estimated_speed": event_data["max_estimated_speed"],
212214
"type": "object",
213215
"max_severity": event_data.get("max_severity"),
214216
},

frigate/object_processing.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,12 @@ def get_current_frame(self, draw_options={}):
162162
box[2],
163163
box[3],
164164
text,
165-
f"{obj['score']:.0%} {int(obj['area'])}",
165+
f"{obj['score']:.0%} {int(obj['area'])}"
166+
+ (
167+
f" {float(obj['estimated_speed']):.1f}"
168+
if obj["estimated_speed"] != 0
169+
else ""
170+
),
166171
thickness=thickness,
167172
color=color,
168173
)
@@ -256,6 +261,7 @@ def update(
256261
new_obj = tracked_objects[id] = TrackedObject(
257262
self.config.model,
258263
self.camera_config,
264+
self.config.ui,
259265
self.frame_cache,
260266
current_detections[id],
261267
)

frigate/track/tracked_object.py

+39-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from frigate.config import (
1313
CameraConfig,
1414
ModelConfig,
15+
UIConfig,
1516
)
1617
from frigate.review.types import SeverityEnum
1718
from frigate.util.image import (
@@ -22,6 +23,7 @@
2223
is_better_thumbnail,
2324
)
2425
from frigate.util.object import box_inside
26+
from frigate.util.velocity import calculate_real_world_speed
2527

2628
logger = logging.getLogger(__name__)
2729

@@ -31,6 +33,7 @@ def __init__(
3133
self,
3234
model_config: ModelConfig,
3335
camera_config: CameraConfig,
36+
ui_config: UIConfig,
3437
frame_cache,
3538
obj_data: dict[str, any],
3639
):
@@ -42,6 +45,7 @@ def __init__(
4245
self.colormap = model_config.colormap
4346
self.logos = model_config.all_attribute_logos
4447
self.camera_config = camera_config
48+
self.ui_config = ui_config
4549
self.frame_cache = frame_cache
4650
self.zone_presence: dict[str, int] = {}
4751
self.zone_loitering: dict[str, int] = {}
@@ -58,6 +62,9 @@ def __init__(
5862
self.frame = None
5963
self.active = True
6064
self.pending_loitering = False
65+
self.estimated_speed = 0
66+
self.max_estimated_speed = 0
67+
self.velocity_angle = 0
6168
self.previous = self.to_dict()
6269

6370
@property
@@ -129,6 +136,7 @@ def update(self, current_frame_time: float, obj_data, has_valid_frame: bool):
129136
"region": obj_data["region"],
130137
"score": obj_data["score"],
131138
"attributes": obj_data["attributes"],
139+
"estimated_speed": self.estimated_speed,
132140
}
133141
thumb_update = True
134142

@@ -174,6 +182,32 @@ def update(self, current_frame_time: float, obj_data, has_valid_frame: bool):
174182
if 0 < zone_score < zone.inertia:
175183
self.zone_presence[name] = zone_score - 1
176184

185+
# update speed
186+
if zone.distances and name in self.entered_zones:
187+
speed_magnitude, self.velocity_angle = (
188+
calculate_real_world_speed(
189+
zone.contour,
190+
zone.distances,
191+
self.obj_data["estimate_velocity"],
192+
bottom_center,
193+
self.camera_config.detect.fps,
194+
)
195+
if self.active
196+
else 0
197+
)
198+
if self.ui_config.unit_system == "metric":
199+
# Convert m/s to km/h
200+
self.estimated_speed = speed_magnitude * 3.6
201+
elif self.ui_config.unit_system == "imperial":
202+
# Convert ft/s to mph
203+
self.estimated_speed = speed_magnitude * 0.681818
204+
logger.debug(
205+
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}"
206+
)
207+
208+
if self.estimated_speed > self.max_estimated_speed:
209+
self.max_estimated_speed = self.estimated_speed
210+
177211
# update loitering status
178212
self.pending_loitering = in_loitering_zone
179213

@@ -255,6 +289,9 @@ def to_dict(self, include_thumbnail: bool = False):
255289
"current_attributes": self.obj_data["attributes"],
256290
"pending_loitering": self.pending_loitering,
257291
"max_severity": self.max_severity,
292+
"estimated_speed": self.estimated_speed,
293+
"max_estimated_speed": self.max_estimated_speed,
294+
"velocity_angle": self.velocity_angle,
258295
}
259296

260297
if include_thumbnail:
@@ -339,7 +376,8 @@ def get_jpg_bytes(
339376
box[2],
340377
box[3],
341378
self.obj_data["label"],
342-
f"{int(self.thumbnail_data['score']*100)}% {int(self.thumbnail_data['area'])}",
379+
f"{int(self.thumbnail_data['score']*100)}% {int(self.thumbnail_data['area'])}"
380+
+ (f" {self.estimated_speed:.1f}" if self.estimated_speed != 0 else ""),
343381
thickness=thickness,
344382
color=color,
345383
)

frigate/util/velocity.py

+90
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import math
2+
3+
import numpy as np
4+
5+
6+
def create_ground_plane(zone_points, distances):
7+
"""
8+
Create a ground plane that accounts for perspective distortion using real-world dimensions for each side of the zone.
9+
10+
:param zone_points: Array of zone corner points in pixel coordinates in circular order
11+
[[x1, y1], [x2, y2], [x3, y3], [x4, y4]]
12+
:param distances: Real-world dimensions ordered by A, B, C, D
13+
:return: Function that calculates real-world distance per pixel at any coordinate
14+
"""
15+
A, B, C, D = zone_points
16+
17+
# Calculate pixel lengths of each side
18+
AB_px = np.linalg.norm(np.array(B) - np.array(A))
19+
BC_px = np.linalg.norm(np.array(C) - np.array(B))
20+
CD_px = np.linalg.norm(np.array(D) - np.array(C))
21+
DA_px = np.linalg.norm(np.array(A) - np.array(D))
22+
23+
AB, BC, CD, DA = map(float, distances)
24+
25+
AB_scale = AB / AB_px
26+
BC_scale = BC / BC_px
27+
CD_scale = CD / CD_px
28+
DA_scale = DA / DA_px
29+
30+
def distance_per_pixel(x, y):
31+
"""
32+
Calculate the real-world distance per pixel at a given (x, y) coordinate.
33+
34+
:param x: X-coordinate in the image
35+
:param y: Y-coordinate in the image
36+
:return: Real-world distance per pixel at the given (x, y) coordinate
37+
"""
38+
# Normalize x and y within the zone
39+
x_norm = (x - A[0]) / (B[0] - A[0])
40+
y_norm = (y - A[1]) / (D[1] - A[1])
41+
42+
# Interpolate scales horizontally and vertically
43+
vertical_scale = AB_scale + (CD_scale - AB_scale) * y_norm
44+
horizontal_scale = DA_scale + (BC_scale - DA_scale) * x_norm
45+
46+
# Combine horizontal and vertical scales
47+
return (vertical_scale + horizontal_scale) / 2
48+
49+
return distance_per_pixel
50+
51+
52+
def calculate_real_world_speed(
53+
zone_contour,
54+
distances,
55+
velocity_pixels,
56+
position,
57+
camera_fps,
58+
):
59+
"""
60+
Calculate the real-world speed of a tracked object, accounting for perspective,
61+
directly from the zone string.
62+
63+
:param zone_contour: Array of absolute zone points
64+
:param distances: Comma separated distances of each side, ordered by A, B, C, D
65+
:param velocity_pixels: List of tuples representing velocity in pixels/frame
66+
:param position: Current position of the object (x, y) in pixels
67+
:param camera_fps: Frames per second of the camera
68+
:return: speed and velocity angle direction
69+
"""
70+
ground_plane = create_ground_plane(zone_contour, distances)
71+
72+
if not isinstance(velocity_pixels, np.ndarray):
73+
velocity_pixels = np.array(velocity_pixels)
74+
75+
avg_velocity_pixels = velocity_pixels.mean(axis=0)
76+
77+
# get the real-world distance per pixel at the object's current position and calculate real speed
78+
scale = ground_plane(position[0], position[1])
79+
speed_real = avg_velocity_pixels * scale * camera_fps
80+
81+
# euclidean speed in real-world units/second
82+
speed_magnitude = np.linalg.norm(speed_real)
83+
84+
# movement direction
85+
dx, dy = avg_velocity_pixels
86+
angle = math.degrees(math.atan2(dy, dx))
87+
if angle < 0:
88+
angle += 360
89+
90+
return speed_magnitude, angle

0 commit comments

Comments
 (0)