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 # this uses the base model because the color is an extra attribute
import logging
from typing import Optional, Union from typing import Optional, Union
import numpy as np 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 from .objects import FilterConfig
__all__ = ["ZoneConfig"] __all__ = ["ZoneConfig"]
logger = logging.getLogger(__name__)
class ZoneConfig(BaseModel): class ZoneConfig(BaseModel):
filters: dict[str, FilterConfig] = Field( filters: dict[str, FilterConfig] = Field(
@ -30,6 +33,11 @@ class ZoneConfig(BaseModel):
ge=0, ge=0,
title="Number of seconds that an object must loiter to be considered in the zone.", 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( objects: Union[str, list[str]] = Field(
default_factory=list, default_factory=list,
title="List of objects that can trigger the zone.", title="List of objects that can trigger the zone.",
@ -71,6 +79,16 @@ class ZoneConfig(BaseModel):
return distances 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): def __init__(self, **config):
super().__init__(**config) super().__init__(**config)
@ -107,7 +125,7 @@ class ZoneConfig(BaseModel):
if explicit: if explicit:
self.coordinates = ",".join( 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 for p in coordinates
] ]
) )

View File

@ -146,6 +146,7 @@ class TrackedObject:
current_zones = [] current_zones = []
bottom_center = (obj_data["centroid"][0], obj_data["box"][3]) bottom_center = (obj_data["centroid"][0], obj_data["box"][3])
in_loitering_zone = False in_loitering_zone = False
in_speed_zone = False
# check each zone # check each zone
for name, zone in self.camera_config.zones.items(): for name, zone in self.camera_config.zones.items():
@ -154,12 +155,66 @@ class TrackedObject:
continue continue
contour = zone.contour contour = zone.contour
zone_score = self.zone_presence.get(name, 0) + 1 zone_score = self.zone_presence.get(name, 0) + 1
# check if the object is in the zone # check if the object is in the zone
if cv2.pointPolygonTest(contour, bottom_center, False) >= 0: if cv2.pointPolygonTest(contour, bottom_center, False) >= 0:
# if the object passed the filters once, dont apply again # if the object passed the filters once, dont apply again
if name in self.current_zones or not zone_filtered(self, zone.filters): 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_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 the zone has loitering time, update loitering status
if zone.loitering_time > 0: if zone.loitering_time > 0:
in_loitering_zone = True in_loitering_zone = True
@ -184,44 +239,8 @@ class TrackedObject:
if 0 < zone_score < zone.inertia: if 0 < zone_score < zone.inertia:
self.zone_presence[name] = zone_score - 1 self.zone_presence[name] = zone_score - 1
# update speed # Reset speed if not in speed zone
if ( if zone.distances and name not in current_zones:
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
self.current_estimated_speed = 0 self.current_estimated_speed = 0
# update loitering status # update loitering status
@ -392,7 +411,7 @@ class TrackedObject:
box[2], box[2],
box[3], box[3],
self.obj_data["label"], 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}" f" {self.thumbnail_data['current_estimated_speed']:.1f}"
if self.thumbnail_data["current_estimated_speed"] != 0 if self.thumbnail_data["current_estimated_speed"] != 0

View File

@ -181,6 +181,13 @@ export default function ZoneEditPane({
}) })
.optional() .optional()
.or(z.literal("")), .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( .refine(
(data) => { (data) => {
@ -193,6 +200,21 @@ export default function ZoneEditPane({
message: "All distance fields must be filled to use speed estimation.", message: "All distance fields must be filled to use speed estimation.",
path: ["speedEstimation"], 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>>({ const form = useForm<z.infer<typeof formSchema>>({
@ -215,6 +237,10 @@ export default function ZoneEditPane({
lineB, lineB,
lineC, lineC,
lineD, 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, lineB,
lineC, lineC,
lineD, lineD,
speed_threshold,
}: ZoneFormValuesType, // values submitted via the form }: ZoneFormValuesType, // values submitted via the form
objects: string[], 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 axios
.put( .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 }, { requires_restart: 0 },
) )
.then((res) => { .then((res) => {
@ -573,6 +613,14 @@ export default function ZoneEditPane({
); );
return; 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); field.onChange(checked);
}} }}
/> />
@ -607,6 +655,7 @@ export default function ZoneEditPane({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <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} {...field}
onFocus={() => setActiveLine(1)} onFocus={() => setActiveLine(1)}
onBlur={() => setActiveLine(undefined)} onBlur={() => setActiveLine(undefined)}
@ -629,6 +678,7 @@ export default function ZoneEditPane({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <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} {...field}
onFocus={() => setActiveLine(2)} onFocus={() => setActiveLine(2)}
onBlur={() => setActiveLine(undefined)} onBlur={() => setActiveLine(undefined)}
@ -651,6 +701,7 @@ export default function ZoneEditPane({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <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} {...field}
onFocus={() => setActiveLine(3)} onFocus={() => setActiveLine(3)}
onBlur={() => setActiveLine(undefined)} onBlur={() => setActiveLine(undefined)}
@ -673,6 +724,7 @@ export default function ZoneEditPane({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <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} {...field}
onFocus={() => setActiveLine(4)} onFocus={() => setActiveLine(4)}
onBlur={() => setActiveLine(undefined)} onBlur={() => setActiveLine(undefined)}
@ -681,6 +733,31 @@ export default function ZoneEditPane({
</FormItem> </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; lineB: number;
lineC: number; lineC: number;
lineD: number; lineD: number;
speed_threshold: number;
}; };
export type ObjectMaskFormValuesType = { export type ObjectMaskFormValuesType = {

View File

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