implement speed_threshold for zone presence

This commit is contained in:
Josh Hawkins 2025-01-30 09:35:30 -06:00
parent 55effc2f8e
commit d12298d9e3
5 changed files with 159 additions and 43 deletions

View File

@ -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
]
)

View File

@ -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

View File

@ -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<z.infer<typeof formSchema>>({
@ -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({
</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
{...field}
onFocus={() => setActiveLine(1)}
onBlur={() => setActiveLine(undefined)}
@ -629,6 +678,7 @@ export default function ZoneEditPane({
</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
{...field}
onFocus={() => setActiveLine(2)}
onBlur={() => setActiveLine(undefined)}
@ -651,6 +701,7 @@ export default function ZoneEditPane({
</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
{...field}
onFocus={() => setActiveLine(3)}
onBlur={() => setActiveLine(undefined)}
@ -673,6 +724,7 @@ export default function ZoneEditPane({
</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
{...field}
onFocus={() => setActiveLine(4)}
onBlur={() => setActiveLine(undefined)}
@ -681,6 +733,31 @@ export default function ZoneEditPane({
</FormItem>
)}
/>
<Separator className="my-2 flex bg-secondary" />
<FormField
control={form.control}
name="speed_threshold"
render={({ field }) => (
<FormItem>
<FormLabel>
Speed Threshold (
{config?.ui.unit_system == "imperial" ? "mph" : "kph"})
</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
{...field}
/>
</FormControl>
<FormDescription>
Specifies a minimum speed for objects to be considered
in this zone.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}

View File

@ -24,6 +24,7 @@ export type ZoneFormValuesType = {
lineB: number;
lineC: number;
lineD: number;
speed_threshold: number;
};
export type ObjectMaskFormValuesType = {

View File

@ -219,6 +219,7 @@ export interface CameraConfig {
filters: Record<string, unknown>;
inertia: number;
loitering_time: number;
speed_threshold: number;
objects: string[];
color: number[];
};