mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-07-26 13:47:03 +02:00
Estimated object speed for zones (#16452)
* utility functions * backend config * backend object speed tracking * draw speed on debug view * basic frontend zone editor * remove line sorting * fix types * highlight line on canvas when entering value in zone edit pane * rename vars and add validation * ensure speed estimation is disabled when user adds more than 4 points * pixel velocity in debug * unit_system in config * ability to define unit system in config * save max speed to db * frontend * docs * clarify docs * utility functions * backend config * backend object speed tracking * draw speed on debug view * basic frontend zone editor * remove line sorting * fix types * highlight line on canvas when entering value in zone edit pane * rename vars and add validation * ensure speed estimation is disabled when user adds more than 4 points * pixel velocity in debug * unit_system in config * ability to define unit system in config * save max speed to db * frontend * docs * clarify docs * fix duplicates from merge * include max_estimated_speed in api responses * add units to zone edit pane * catch undefined * add average speed * clarify docs * only track average speed when object is active * rename vars * ensure points and distances are ordered clockwise * only store the last 10 speeds like score history * remove max estimated speed * update docs * update docs * fix point ordering * improve readability * docs inertia recommendation * fix point ordering * check object frame time * add velocity angle to frontend * docs clarity * add frontend speed filter * fix mqtt docs * fix mqtt docs * don't try to remove distances if they weren't already defined * don't display estimates on debug view/snapshots if object is not in a speed tracking zone * docs * implement speed_threshold for zone presence * docs for threshold * better ground plane image * improve image zone size * add inertia to speed threshold example
This commit is contained in:
parent
dd7820e4ee
commit
72209986b6
@ -662,7 +662,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)
|
||||
@ -813,6 +816,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:
|
||||
|
@ -122,16 +122,59 @@ 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 as they move through the zone.
|
||||
|
||||
Your zone must be defined with exactly 4 points and should be aligned to the ground where objects are moving.
|
||||
|
||||

|
||||
|
||||
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 places where objects enter and exit for the best results. _Your zone should not take up the full frame._ An object's speed is tracked while it is in the zone and then saved to Frigate's database.
|
||||
|
||||
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 average speed of your object as it moved through your zone 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 (see the caveats below). Current estimated speed, average 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). These speed values are output as a number in miles per hour (mph) or kilometers per hour (kph), depending on how `unit_system` is configured in your `ui` config.
|
||||
|
||||
#### 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 and does not become obscured at any time.
|
||||
- Depending on the size and location of your zone, you may want to decrease the zone's `inertia` value from the default of 3.
|
||||
- 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.
|
||||
- Once an object leaves the zone, speed accuracy will likely decrease due to perspective distortion and misalignment with the calibrated area. Therefore, speed values will show as a zero through MQTT and will not be visible on the debug view when an object is outside of a speed tracking zone.
|
||||
- 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.
|
||||
|
||||
### Speed Threshold
|
||||
|
||||
Zones can be configured with a minimum speed requirement, meaning an object must be moving at or above this speed to be considered inside the zone. Zone `distances` must be defined as described above.
|
||||
|
||||
```yaml
|
||||
cameras:
|
||||
name_of_your_camera:
|
||||
zones:
|
||||
sidewalk:
|
||||
coordinates: ...
|
||||
distances: ...
|
||||
inertia: 1
|
||||
speed_threshold: 20 # unit is in kph or mph, depending on how unit_system is set (see above)
|
||||
```
|
||||
|
@ -52,7 +52,9 @@ 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
|
||||
"current_estimated_speed": 0.71, // current 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 +91,9 @@ Message published for each changed tracked object. The first message is publishe
|
||||
"box": [442, 506, 534, 524],
|
||||
"score": 0.86
|
||||
}
|
||||
]
|
||||
],
|
||||
"current_estimated_speed": 0.77, // current 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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
BIN
docs/static/img/ground-plane.jpg
vendored
Normal file
BIN
docs/static/img/ground-plane.jpg
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 231 KiB |
@ -25,6 +25,8 @@ class EventsQueryParams(BaseModel):
|
||||
favorites: Optional[int] = None
|
||||
min_score: Optional[float] = None
|
||||
max_score: Optional[float] = None
|
||||
min_speed: Optional[float] = None
|
||||
max_speed: Optional[float] = None
|
||||
is_submitted: Optional[int] = None
|
||||
min_length: Optional[float] = None
|
||||
max_length: Optional[float] = None
|
||||
@ -51,6 +53,8 @@ class EventsSearchQueryParams(BaseModel):
|
||||
timezone: Optional[str] = "utc"
|
||||
min_score: Optional[float] = None
|
||||
max_score: Optional[float] = None
|
||||
min_speed: Optional[float] = None
|
||||
max_speed: Optional[float] = None
|
||||
sort: Optional[str] = None
|
||||
|
||||
|
||||
|
@ -92,6 +92,8 @@ def events(params: EventsQueryParams = Depends()):
|
||||
favorites = params.favorites
|
||||
min_score = params.min_score
|
||||
max_score = params.max_score
|
||||
min_speed = params.min_speed
|
||||
max_speed = params.max_speed
|
||||
is_submitted = params.is_submitted
|
||||
min_length = params.min_length
|
||||
max_length = params.max_length
|
||||
@ -226,6 +228,12 @@ def events(params: EventsQueryParams = Depends()):
|
||||
if min_score is not None:
|
||||
clauses.append((Event.data["score"] >= min_score))
|
||||
|
||||
if max_speed is not None:
|
||||
clauses.append((Event.data["average_estimated_speed"] <= max_speed))
|
||||
|
||||
if min_speed is not None:
|
||||
clauses.append((Event.data["average_estimated_speed"] >= min_speed))
|
||||
|
||||
if min_length is not None:
|
||||
clauses.append(((Event.end_time - Event.start_time) >= min_length))
|
||||
|
||||
@ -249,6 +257,10 @@ def events(params: EventsQueryParams = Depends()):
|
||||
order_by = Event.data["score"].asc()
|
||||
elif sort == "score_desc":
|
||||
order_by = Event.data["score"].desc()
|
||||
elif sort == "speed_asc":
|
||||
order_by = Event.data["average_estimated_speed"].asc()
|
||||
elif sort == "speed_desc":
|
||||
order_by = Event.data["average_estimated_speed"].desc()
|
||||
elif sort == "date_asc":
|
||||
order_by = Event.start_time.asc()
|
||||
elif sort == "date_desc":
|
||||
@ -316,7 +328,15 @@ def events_explore(limit: int = 10):
|
||||
k: v
|
||||
for k, v in event.data.items()
|
||||
if k
|
||||
in ["type", "score", "top_score", "description", "sub_label_score"]
|
||||
in [
|
||||
"type",
|
||||
"score",
|
||||
"top_score",
|
||||
"description",
|
||||
"sub_label_score",
|
||||
"average_estimated_speed",
|
||||
"velocity_angle",
|
||||
]
|
||||
},
|
||||
"event_count": label_counts[event.label],
|
||||
}
|
||||
@ -367,6 +387,8 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
|
||||
before = params.before
|
||||
min_score = params.min_score
|
||||
max_score = params.max_score
|
||||
min_speed = params.min_speed
|
||||
max_speed = params.max_speed
|
||||
time_range = params.time_range
|
||||
has_clip = params.has_clip
|
||||
has_snapshot = params.has_snapshot
|
||||
@ -466,6 +488,16 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
|
||||
if max_score is not None:
|
||||
event_filters.append((Event.data["score"] <= max_score))
|
||||
|
||||
if min_speed is not None and max_speed is not None:
|
||||
event_filters.append(
|
||||
(Event.data["average_estimated_speed"].between(min_speed, max_speed))
|
||||
)
|
||||
else:
|
||||
if min_speed is not None:
|
||||
event_filters.append((Event.data["average_estimated_speed"] >= min_speed))
|
||||
if max_speed is not None:
|
||||
event_filters.append((Event.data["average_estimated_speed"] <= max_speed))
|
||||
|
||||
if time_range != DEFAULT_TIME_RANGE:
|
||||
tz_name = params.timezone
|
||||
hour_modifier, minute_modifier, _ = get_tz_modifiers(tz_name)
|
||||
@ -581,7 +613,16 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
|
||||
processed_event["data"] = {
|
||||
k: v
|
||||
for k, v in event["data"].items()
|
||||
if k in ["type", "score", "top_score", "description"]
|
||||
if k
|
||||
in [
|
||||
"type",
|
||||
"score",
|
||||
"top_score",
|
||||
"description",
|
||||
"sub_label_score",
|
||||
"average_estimated_speed",
|
||||
"velocity_angle",
|
||||
]
|
||||
}
|
||||
|
||||
if event["id"] in search_results:
|
||||
@ -596,6 +637,10 @@ def events_search(request: Request, params: EventsSearchQueryParams = Depends())
|
||||
processed_events.sort(key=lambda x: x["score"])
|
||||
elif min_score is not None and max_score is not None and sort == "score_desc":
|
||||
processed_events.sort(key=lambda x: x["score"], reverse=True)
|
||||
elif min_speed is not None and max_speed is not None and sort == "speed_asc":
|
||||
processed_events.sort(key=lambda x: x["average_estimated_speed"])
|
||||
elif min_speed is not None and max_speed is not None and sort == "speed_desc":
|
||||
processed_events.sort(key=lambda x: x["average_estimated_speed"], reverse=True)
|
||||
elif sort == "date_asc":
|
||||
processed_events.sort(key=lambda x: x["start_time"])
|
||||
else:
|
||||
|
@ -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(
|
||||
@ -16,6 +19,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.",
|
||||
@ -26,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.",
|
||||
@ -49,6 +61,34 @@ class ZoneConfig(BaseModel):
|
||||
|
||||
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
|
||||
|
||||
@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)
|
||||
|
||||
|
@ -5,7 +5,7 @@ from pydantic import Field
|
||||
|
||||
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."
|
||||
)
|
||||
|
@ -25,6 +25,9 @@ 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["average_estimated_speed"]
|
||||
!= current_event["average_estimated_speed"]
|
||||
or prev_event["velocity_angle"] != current_event["velocity_angle"]
|
||||
):
|
||||
return True
|
||||
return False
|
||||
@ -210,6 +213,8 @@ class EventProcessor(threading.Thread):
|
||||
"score": score,
|
||||
"top_score": event_data["top_score"],
|
||||
"attributes": attributes,
|
||||
"average_estimated_speed": event_data["average_estimated_speed"],
|
||||
"velocity_angle": event_data["velocity_angle"],
|
||||
"type": "object",
|
||||
"max_severity": event_data.get("max_severity"),
|
||||
},
|
||||
|
@ -160,7 +160,12 @@ class CameraState:
|
||||
box[2],
|
||||
box[3],
|
||||
text,
|
||||
f"{obj['score']:.0%} {int(obj['area'])}",
|
||||
f"{obj['score']:.0%} {int(obj['area'])}"
|
||||
+ (
|
||||
f" {float(obj['current_estimated_speed']):.1f}"
|
||||
if obj["current_estimated_speed"] != 0
|
||||
else ""
|
||||
),
|
||||
thickness=thickness,
|
||||
color=color,
|
||||
)
|
||||
@ -254,6 +259,7 @@ class CameraState:
|
||||
new_obj = tracked_objects[id] = TrackedObject(
|
||||
self.config.model,
|
||||
self.camera_config,
|
||||
self.config.ui,
|
||||
self.frame_cache,
|
||||
current_detections[id],
|
||||
)
|
||||
|
@ -12,6 +12,7 @@ import numpy as np
|
||||
from frigate.config import (
|
||||
CameraConfig,
|
||||
ModelConfig,
|
||||
UIConfig,
|
||||
)
|
||||
from frigate.review.types import SeverityEnum
|
||||
from frigate.util.image import (
|
||||
@ -22,6 +23,7 @@ from frigate.util.image import (
|
||||
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 @@ class TrackedObject:
|
||||
self,
|
||||
model_config: ModelConfig,
|
||||
camera_config: CameraConfig,
|
||||
ui_config: UIConfig,
|
||||
frame_cache,
|
||||
obj_data: dict[str, any],
|
||||
):
|
||||
@ -42,6 +45,7 @@ class TrackedObject:
|
||||
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,10 @@ class TrackedObject:
|
||||
self.frame = None
|
||||
self.active = True
|
||||
self.pending_loitering = False
|
||||
self.speed_history = []
|
||||
self.current_estimated_speed = 0
|
||||
self.average_estimated_speed = 0
|
||||
self.velocity_angle = 0
|
||||
self.previous = self.to_dict()
|
||||
|
||||
@property
|
||||
@ -129,6 +137,8 @@ class TrackedObject:
|
||||
"region": obj_data["region"],
|
||||
"score": obj_data["score"],
|
||||
"attributes": obj_data["attributes"],
|
||||
"current_estimated_speed": self.current_estimated_speed,
|
||||
"velocity_angle": self.velocity_angle,
|
||||
}
|
||||
thumb_update = True
|
||||
|
||||
@ -136,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():
|
||||
@ -144,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
|
||||
@ -174,6 +239,10 @@ class TrackedObject:
|
||||
if 0 < zone_score < zone.inertia:
|
||||
self.zone_presence[name] = zone_score - 1
|
||||
|
||||
# Reset speed if not in speed zone
|
||||
if zone.distances and name not in current_zones:
|
||||
self.current_estimated_speed = 0
|
||||
|
||||
# update loitering status
|
||||
self.pending_loitering = in_loitering_zone
|
||||
|
||||
@ -255,6 +324,9 @@ class TrackedObject:
|
||||
"current_attributes": self.obj_data["attributes"],
|
||||
"pending_loitering": self.pending_loitering,
|
||||
"max_severity": self.max_severity,
|
||||
"current_estimated_speed": self.current_estimated_speed,
|
||||
"average_estimated_speed": self.average_estimated_speed,
|
||||
"velocity_angle": self.velocity_angle,
|
||||
}
|
||||
|
||||
if include_thumbnail:
|
||||
@ -339,7 +411,12 @@ 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
|
||||
else ""
|
||||
),
|
||||
thickness=thickness,
|
||||
color=color,
|
||||
)
|
||||
|
127
frigate/util/velocity.py
Normal file
127
frigate/util/velocity.py
Normal file
@ -0,0 +1,127 @@
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def order_points_clockwise(points):
|
||||
"""
|
||||
Ensure points are sorted in clockwise order starting from the top left
|
||||
|
||||
:param points: Array of zone corner points in pixel coordinates
|
||||
:return: Ordered list of points
|
||||
"""
|
||||
top_left = min(
|
||||
points, key=lambda p: (p[1], p[0])
|
||||
) # Find the top-left point (min y, then x)
|
||||
|
||||
# Remove the top-left point from the list of points
|
||||
remaining_points = [p for p in points if not np.array_equal(p, top_left)]
|
||||
|
||||
# Sort the remaining points based on the angle relative to the top-left point
|
||||
def angle_from_top_left(point):
|
||||
x, y = point[0] - top_left[0], point[1] - top_left[1]
|
||||
return math.atan2(y, x)
|
||||
|
||||
sorted_points = sorted(remaining_points, key=angle_from_top_left)
|
||||
|
||||
return [top_left] + sorted_points
|
||||
|
||||
|
||||
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
|
||||
[[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: List of 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
|
||||
"""
|
||||
# order the zone_contour points clockwise starting at top left
|
||||
ordered_zone_contour = order_points_clockwise(zone_contour)
|
||||
|
||||
# find the indices that would sort the original zone_contour to match ordered_zone_contour
|
||||
sort_indices = [
|
||||
np.where((zone_contour == point).all(axis=1))[0][0]
|
||||
for point in ordered_zone_contour
|
||||
]
|
||||
|
||||
# Reorder distances to match the new order of zone_contour
|
||||
distances = np.array(distances)
|
||||
ordered_distances = distances[sort_indices]
|
||||
|
||||
ground_plane = create_ground_plane(ordered_zone_contour, ordered_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
|
@ -116,6 +116,9 @@ export default function SearchFilterGroup({
|
||||
if (filter?.min_score || filter?.max_score) {
|
||||
sortTypes.push("score_desc", "score_asc");
|
||||
}
|
||||
if (filter?.min_speed || filter?.max_speed) {
|
||||
sortTypes.push("speed_desc", "speed_asc");
|
||||
}
|
||||
if (filter?.event_id || filter?.query) {
|
||||
sortTypes.push("relevance");
|
||||
}
|
||||
@ -498,6 +501,8 @@ export function SortTypeContent({
|
||||
date_desc: "Date (Descending)",
|
||||
score_asc: "Object Score (Ascending)",
|
||||
score_desc: "Object Score (Descending)",
|
||||
speed_asc: "Estimated Speed (Ascending)",
|
||||
speed_desc: "Estimated Speed (Descending)",
|
||||
relevance: "Relevance",
|
||||
};
|
||||
|
||||
|
@ -216,11 +216,14 @@ export default function InputWithTags({
|
||||
type == "after" ||
|
||||
type == "time_range" ||
|
||||
type == "min_score" ||
|
||||
type == "max_score"
|
||||
type == "max_score" ||
|
||||
type == "min_speed" ||
|
||||
type == "max_speed"
|
||||
) {
|
||||
const newFilters = { ...filters };
|
||||
let timestamp = 0;
|
||||
let score = 0;
|
||||
let speed = 0;
|
||||
|
||||
switch (type) {
|
||||
case "before":
|
||||
@ -294,6 +297,40 @@ export default function InputWithTags({
|
||||
newFilters[type] = score / 100;
|
||||
}
|
||||
break;
|
||||
case "min_speed":
|
||||
case "max_speed":
|
||||
speed = parseFloat(value);
|
||||
if (score >= 0) {
|
||||
// Check for conflicts between min_speed and max_speed
|
||||
if (
|
||||
type === "min_speed" &&
|
||||
filters.max_speed !== undefined &&
|
||||
speed > filters.max_speed
|
||||
) {
|
||||
toast.error(
|
||||
"The 'min_speed' must be less than or equal to the 'max_speed'.",
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
type === "max_speed" &&
|
||||
filters.min_speed !== undefined &&
|
||||
speed < filters.min_speed
|
||||
) {
|
||||
toast.error(
|
||||
"The 'max_speed' must be greater than or equal to the 'min_speed'.",
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
newFilters[type] = speed;
|
||||
}
|
||||
break;
|
||||
case "time_range":
|
||||
newFilters[type] = value;
|
||||
break;
|
||||
@ -369,6 +406,10 @@ export default function InputWithTags({
|
||||
}`;
|
||||
} else if (filterType === "min_score" || filterType === "max_score") {
|
||||
return Math.round(Number(filterValues) * 100).toString() + "%";
|
||||
} else if (filterType === "min_speed" || filterType === "max_speed") {
|
||||
return (
|
||||
filterValues + (config?.ui.unit_system == "metric" ? " kph" : " mph")
|
||||
);
|
||||
} else if (
|
||||
filterType === "has_clip" ||
|
||||
filterType === "has_snapshot" ||
|
||||
@ -397,7 +438,11 @@ export default function InputWithTags({
|
||||
((filterType === "min_score" || filterType === "max_score") &&
|
||||
!isNaN(Number(trimmedValue)) &&
|
||||
Number(trimmedValue) >= 50 &&
|
||||
Number(trimmedValue) <= 100)
|
||||
Number(trimmedValue) <= 100) ||
|
||||
((filterType === "min_speed" || filterType === "max_speed") &&
|
||||
!isNaN(Number(trimmedValue)) &&
|
||||
Number(trimmedValue) >= 1 &&
|
||||
Number(trimmedValue) <= 150)
|
||||
) {
|
||||
createFilter(
|
||||
filterType,
|
||||
|
@ -25,6 +25,7 @@ import { baseUrl } from "@/api/baseUrl";
|
||||
import { cn } from "@/lib/utils";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import {
|
||||
FaArrowRight,
|
||||
FaCheckCircle,
|
||||
FaChevronDown,
|
||||
FaDownload,
|
||||
@ -329,6 +330,30 @@ function ObjectDetailsTab({
|
||||
}
|
||||
}, [search]);
|
||||
|
||||
const averageEstimatedSpeed = useMemo(() => {
|
||||
if (!search || !search.data?.average_estimated_speed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (search.data?.average_estimated_speed != 0) {
|
||||
return search.data?.average_estimated_speed.toFixed(1);
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}, [search]);
|
||||
|
||||
const velocityAngle = useMemo(() => {
|
||||
if (!search || !search.data?.velocity_angle) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (search.data?.velocity_angle != 0) {
|
||||
return search.data?.velocity_angle.toFixed(1);
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}, [search]);
|
||||
|
||||
const updateDescription = useCallback(() => {
|
||||
if (!search) {
|
||||
return;
|
||||
@ -440,6 +465,29 @@ function ObjectDetailsTab({
|
||||
{score}%{subLabelScore && ` (${subLabelScore}%)`}
|
||||
</div>
|
||||
</div>
|
||||
{averageEstimatedSpeed && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="text-sm text-primary/40">Estimated Speed</div>
|
||||
<div className="flex flex-col space-y-0.5 text-sm">
|
||||
{averageEstimatedSpeed && (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{averageEstimatedSpeed}{" "}
|
||||
{config?.ui.unit_system == "imperial" ? "mph" : "kph"}{" "}
|
||||
{velocityAngle != undefined && (
|
||||
<span className="text-primary/40">
|
||||
<FaArrowRight
|
||||
size={10}
|
||||
style={{
|
||||
transform: `rotate(${(360 - Number(velocityAngle)) % 360}deg)`,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="text-sm text-primary/40">Camera</div>
|
||||
<div className="text-sm capitalize">
|
||||
|
@ -71,9 +71,11 @@ export default function SearchFilterDialog({
|
||||
currentFilter &&
|
||||
(currentFilter.time_range ||
|
||||
(currentFilter.min_score ?? 0) > 0.5 ||
|
||||
(currentFilter.min_speed ?? 1) > 1 ||
|
||||
(currentFilter.has_snapshot ?? 0) === 1 ||
|
||||
(currentFilter.has_clip ?? 0) === 1 ||
|
||||
(currentFilter.max_score ?? 1) < 1 ||
|
||||
(currentFilter.max_speed ?? 150) < 150 ||
|
||||
(currentFilter.zones?.length ?? 0) > 0 ||
|
||||
(currentFilter.sub_labels?.length ?? 0) > 0),
|
||||
[currentFilter],
|
||||
@ -124,6 +126,14 @@ export default function SearchFilterDialog({
|
||||
setCurrentFilter({ ...currentFilter, min_score: min, max_score: max })
|
||||
}
|
||||
/>
|
||||
<SpeedFilterContent
|
||||
config={config}
|
||||
minSpeed={currentFilter.min_speed}
|
||||
maxSpeed={currentFilter.max_speed}
|
||||
setSpeedRange={(min, max) =>
|
||||
setCurrentFilter({ ...currentFilter, min_speed: min, max_speed: max })
|
||||
}
|
||||
/>
|
||||
<SnapshotClipFilterContent
|
||||
config={config}
|
||||
hasSnapshot={
|
||||
@ -178,6 +188,8 @@ export default function SearchFilterDialog({
|
||||
search_type: undefined,
|
||||
min_score: undefined,
|
||||
max_score: undefined,
|
||||
min_speed: undefined,
|
||||
max_speed: undefined,
|
||||
has_snapshot: undefined,
|
||||
has_clip: undefined,
|
||||
}));
|
||||
@ -521,6 +533,62 @@ export function ScoreFilterContent({
|
||||
);
|
||||
}
|
||||
|
||||
type SpeedFilterContentProps = {
|
||||
config?: FrigateConfig;
|
||||
minSpeed: number | undefined;
|
||||
maxSpeed: number | undefined;
|
||||
setSpeedRange: (min: number | undefined, max: number | undefined) => void;
|
||||
};
|
||||
export function SpeedFilterContent({
|
||||
config,
|
||||
minSpeed,
|
||||
maxSpeed,
|
||||
setSpeedRange,
|
||||
}: SpeedFilterContentProps) {
|
||||
return (
|
||||
<div className="overflow-x-hidden">
|
||||
<DropdownMenuSeparator className="mb-3" />
|
||||
<div className="mb-3 text-lg">
|
||||
Estimated Speed ({config?.ui.unit_system == "metric" ? "kph" : "mph"})
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
className="w-14 text-center"
|
||||
inputMode="numeric"
|
||||
value={minSpeed ?? 1}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
|
||||
if (value) {
|
||||
setSpeedRange(parseInt(value), maxSpeed ?? 1.0);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<DualThumbSlider
|
||||
className="mx-2 w-full"
|
||||
min={1}
|
||||
max={150}
|
||||
step={1}
|
||||
value={[minSpeed ?? 1, maxSpeed ?? 150]}
|
||||
onValueChange={([min, max]) => setSpeedRange(min, max)}
|
||||
/>
|
||||
<Input
|
||||
className="w-14 text-center"
|
||||
inputMode="numeric"
|
||||
value={maxSpeed ?? 150}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
|
||||
if (value) {
|
||||
setSpeedRange(minSpeed ?? 1, parseInt(value));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SnapshotClipContentProps = {
|
||||
config?: FrigateConfig;
|
||||
hasSnapshot: boolean | undefined;
|
||||
|
@ -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<HTMLImageElement | undefined>();
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
</Layer>
|
||||
|
@ -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<Konva.Stage>;
|
||||
points: number[][];
|
||||
distances: number[];
|
||||
isActive: boolean;
|
||||
isHovered: boolean;
|
||||
isFinished: boolean;
|
||||
color: number[];
|
||||
handlePointDragMove: (e: KonvaEventObject<MouseEvent | TouchEvent>) => void;
|
||||
handleGroupDragEnd: (e: KonvaEventObject<MouseEvent | TouchEvent>) => 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 (
|
||||
<Group
|
||||
name="polygon"
|
||||
@ -158,6 +189,14 @@ export default function PolygonDrawer({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{isActive && activeLinePoints.length > 0 && (
|
||||
<Line
|
||||
points={activeLinePoints}
|
||||
stroke="white"
|
||||
strokeWidth={6}
|
||||
hitStrokeWidth={12}
|
||||
/>
|
||||
)}
|
||||
{points.map((point, index) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
@ -195,6 +234,43 @@ export default function PolygonDrawer({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{isFinished && (
|
||||
<Group>
|
||||
{midpoints.map((midpoint, index) => {
|
||||
const [x, y] = midpoint;
|
||||
const distance = distances[index];
|
||||
if (distance === undefined) return null;
|
||||
|
||||
const squareSize = 22;
|
||||
|
||||
return (
|
||||
<Group
|
||||
key={`distance-group-${index}`}
|
||||
x={x - squareSize / 2}
|
||||
y={y - squareSize / 2}
|
||||
>
|
||||
<Rect
|
||||
width={squareSize}
|
||||
height={squareSize}
|
||||
fill={colorString(true)}
|
||||
stroke="white"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<Text
|
||||
text={`${distance}`}
|
||||
width={squareSize}
|
||||
y={4}
|
||||
fontSize={16}
|
||||
fontFamily="Arial"
|
||||
fill="white"
|
||||
align="center"
|
||||
verticalAlign="middle"
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
@ -40,6 +40,7 @@ type ZoneEditPaneProps = {
|
||||
setIsLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
onSave?: () => void;
|
||||
onCancel?: () => void;
|
||||
setActiveLine: React.Dispatch<React.SetStateAction<number | undefined>>;
|
||||
};
|
||||
|
||||
export default function ZoneEditPane({
|
||||
@ -52,6 +53,7 @@ export default function ZoneEditPane({
|
||||
setIsLoading,
|
||||
onSave,
|
||||
onCancel,
|
||||
setActiveLine,
|
||||
}: ZoneEditPaneProps) {
|
||||
const { data: config, mutate: updateConfig } =
|
||||
useSWR<FrigateConfig>("config");
|
||||
@ -80,69 +82,144 @@ 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) || [];
|
||||
const [lineA, lineB, lineC, lineD] = useMemo(() => {
|
||||
const distances =
|
||||
polygon?.camera &&
|
||||
polygon?.name &&
|
||||
config?.cameras[polygon.camera]?.zones[polygon.name]?.distances;
|
||||
|
||||
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.",
|
||||
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("")),
|
||||
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) => {
|
||||
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"],
|
||||
},
|
||||
)
|
||||
.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>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
mode: "onChange",
|
||||
mode: "onBlur",
|
||||
defaultValues: {
|
||||
name: polygon?.name ?? "",
|
||||
inertia:
|
||||
@ -155,9 +232,31 @@ 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,
|
||||
speed_threshold:
|
||||
polygon?.camera &&
|
||||
polygon?.name &&
|
||||
config?.cameras[polygon.camera]?.zones[polygon.name]?.speed_threshold,
|
||||
},
|
||||
});
|
||||
|
||||
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 +264,12 @@ export default function ZoneEditPane({
|
||||
inertia,
|
||||
loitering_time,
|
||||
objects: form_objects,
|
||||
speedEstimation,
|
||||
lineA,
|
||||
lineB,
|
||||
lineC,
|
||||
lineD,
|
||||
speed_threshold,
|
||||
}: ZoneFormValuesType, // values submitted via the form
|
||||
objects: string[],
|
||||
) => {
|
||||
@ -261,9 +366,32 @@ export default function ZoneEditPane({
|
||||
loiteringTimeQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.loitering_time=${loitering_time}`;
|
||||
}
|
||||
|
||||
let distancesQuery = "";
|
||||
const distances = [lineA, lineB, lineC, lineD].filter(Boolean).join(",");
|
||||
if (speedEstimation) {
|
||||
distancesQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.distances=${distances}`;
|
||||
} else {
|
||||
if (distances != "") {
|
||||
distancesQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.distances`;
|
||||
}
|
||||
}
|
||||
|
||||
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}${objectQueries}${alertQueries}${detectionQueries}`,
|
||||
`config/set?cameras.${polygon?.camera}.zones.${zoneName}.coordinates=${coordinates}${inertiaQuery}${loiteringTimeQuery}${speedThresholdQuery}${distancesQuery}${objectQueries}${alertQueries}${detectionQueries}`,
|
||||
{ requires_restart: 0 },
|
||||
)
|
||||
.then((res) => {
|
||||
@ -456,6 +584,183 @@ export default function ZoneEditPane({
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<Separator className="my-2 flex bg-secondary" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="speedEstimation"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center space-x-2">
|
||||
<FormControl>
|
||||
<div className="my-2.5 flex w-full items-center justify-between">
|
||||
<FormLabel
|
||||
className="cursor-pointer text-primary"
|
||||
htmlFor="allLabels"
|
||||
>
|
||||
Speed Estimation
|
||||
</FormLabel>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
if (
|
||||
checked &&
|
||||
polygons &&
|
||||
activePolygonIndex &&
|
||||
polygons[activePolygonIndex].points.length !== 4
|
||||
) {
|
||||
toast.error(
|
||||
"Zones with speed estimation must have exactly 4 points.",
|
||||
);
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormDescription>
|
||||
Enable speed estimation for objects in this zone. The zone
|
||||
must have exactly 4 points.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.watch("speedEstimation") &&
|
||||
polygons &&
|
||||
activePolygonIndex &&
|
||||
polygons[activePolygonIndex].points.length === 4 && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="lineA"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Line A distance (
|
||||
{config?.ui.unit_system == "imperial"
|
||||
? "feet"
|
||||
: "meters"}
|
||||
)
|
||||
</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)}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="lineB"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Line B distance (
|
||||
{config?.ui.unit_system == "imperial"
|
||||
? "feet"
|
||||
: "meters"}
|
||||
)
|
||||
</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)}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="lineC"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Line C distance (
|
||||
{config?.ui.unit_system == "imperial"
|
||||
? "feet"
|
||||
: "meters"}
|
||||
)
|
||||
</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)}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="lineD"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Line D distance (
|
||||
{config?.ui.unit_system == "imperial"
|
||||
? "feet"
|
||||
: "meters"}
|
||||
)
|
||||
</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)}
|
||||
/>
|
||||
</FormControl>
|
||||
</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>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isFinished"
|
||||
@ -557,7 +862,9 @@ export function ZoneObjectSelector({
|
||||
|
||||
useEffect(() => {
|
||||
updateLabelFilter(currentLabels);
|
||||
}, [currentLabels, updateLabelFilter]);
|
||||
// we know that these deps are correct
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentLabels]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -112,6 +112,8 @@ export default function Explore() {
|
||||
search_type: searchSearchParams["search_type"],
|
||||
min_score: searchSearchParams["min_score"],
|
||||
max_score: searchSearchParams["max_score"],
|
||||
min_speed: searchSearchParams["min_speed"],
|
||||
max_speed: searchSearchParams["max_speed"],
|
||||
has_snapshot: searchSearchParams["has_snapshot"],
|
||||
is_submitted: searchSearchParams["is_submitted"],
|
||||
has_clip: searchSearchParams["has_clip"],
|
||||
@ -145,6 +147,8 @@ export default function Explore() {
|
||||
search_type: searchSearchParams["search_type"],
|
||||
min_score: searchSearchParams["min_score"],
|
||||
max_score: searchSearchParams["max_score"],
|
||||
min_speed: searchSearchParams["min_speed"],
|
||||
max_speed: searchSearchParams["max_speed"],
|
||||
has_snapshot: searchSearchParams["has_snapshot"],
|
||||
is_submitted: searchSearchParams["is_submitted"],
|
||||
has_clip: searchSearchParams["has_clip"],
|
||||
|
@ -8,6 +8,7 @@ export type Polygon = {
|
||||
objects: string[];
|
||||
points: number[][];
|
||||
pointsOrder?: number[];
|
||||
distances: number[];
|
||||
isFinished: boolean;
|
||||
color: number[];
|
||||
};
|
||||
@ -18,6 +19,12 @@ export type ZoneFormValuesType = {
|
||||
loitering_time: number;
|
||||
isFinished: boolean;
|
||||
objects: string[];
|
||||
speedEstimation: boolean;
|
||||
lineA: number;
|
||||
lineB: number;
|
||||
lineC: number;
|
||||
lineD: number;
|
||||
speed_threshold: number;
|
||||
};
|
||||
|
||||
export type ObjectMaskFormValuesType = {
|
||||
|
@ -8,6 +8,7 @@ export interface UiConfig {
|
||||
strftime_fmt?: string;
|
||||
dashboard: boolean;
|
||||
order: number;
|
||||
unit_system?: "metric" | "imperial";
|
||||
}
|
||||
|
||||
export interface BirdseyeConfig {
|
||||
@ -223,9 +224,11 @@ export interface CameraConfig {
|
||||
zones: {
|
||||
[zoneName: string]: {
|
||||
coordinates: string;
|
||||
distances: string[];
|
||||
filters: Record<string, unknown>;
|
||||
inertia: number;
|
||||
loitering_time: number;
|
||||
speed_threshold: number;
|
||||
objects: string[];
|
||||
color: number[];
|
||||
};
|
||||
|
@ -55,6 +55,8 @@ export type SearchResult = {
|
||||
ratio: number;
|
||||
type: "object" | "audio" | "manual";
|
||||
description?: string;
|
||||
average_estimated_speed: number;
|
||||
velocity_angle: number;
|
||||
};
|
||||
};
|
||||
|
||||
@ -68,6 +70,8 @@ export type SearchFilter = {
|
||||
after?: number;
|
||||
min_score?: number;
|
||||
max_score?: number;
|
||||
min_speed?: number;
|
||||
max_speed?: number;
|
||||
has_snapshot?: number;
|
||||
has_clip?: number;
|
||||
is_submitted?: number;
|
||||
@ -89,6 +93,8 @@ export type SearchQueryParams = {
|
||||
after?: string;
|
||||
min_score?: number;
|
||||
max_score?: number;
|
||||
min_speed?: number;
|
||||
max_speed?: number;
|
||||
search_type?: string;
|
||||
limit?: number;
|
||||
in_progress?: number;
|
||||
|
@ -158,6 +158,8 @@ export default function SearchView({
|
||||
after: [formatDateToLocaleString(-5)],
|
||||
min_score: ["50"],
|
||||
max_score: ["100"],
|
||||
min_speed: ["1"],
|
||||
max_speed: ["150"],
|
||||
has_clip: ["yes", "no"],
|
||||
has_snapshot: ["yes", "no"],
|
||||
...(config?.plus?.enabled &&
|
||||
|
@ -61,6 +61,7 @@ export default function MasksAndZonesView({
|
||||
);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [editPane, setEditPane] = useState<PolygonType | undefined>(undefined);
|
||||
const [activeLine, setActiveLine] = useState<number | undefined>();
|
||||
|
||||
const { addMessage } = useContext(StatusBarMessagesContext)!;
|
||||
|
||||
@ -161,6 +162,7 @@ export default function MasksAndZonesView({
|
||||
...(allPolygons || []),
|
||||
{
|
||||
points: [],
|
||||
distances: [],
|
||||
isFinished: false,
|
||||
type,
|
||||
typeIndex: 9999,
|
||||
@ -238,6 +240,8 @@ export default function MasksAndZonesView({
|
||||
scaledWidth,
|
||||
scaledHeight,
|
||||
),
|
||||
distances:
|
||||
zoneData.distances?.map((distance) => parseFloat(distance)) ?? [],
|
||||
isFinished: true,
|
||||
color: zoneData.color,
|
||||
}),
|
||||
@ -267,6 +271,7 @@ export default function MasksAndZonesView({
|
||||
scaledWidth,
|
||||
scaledHeight,
|
||||
),
|
||||
distances: [],
|
||||
isFinished: true,
|
||||
color: [0, 0, 255],
|
||||
}));
|
||||
@ -290,6 +295,7 @@ export default function MasksAndZonesView({
|
||||
scaledWidth,
|
||||
scaledHeight,
|
||||
),
|
||||
distances: [],
|
||||
isFinished: true,
|
||||
color: [128, 128, 128],
|
||||
}));
|
||||
@ -316,6 +322,7 @@ export default function MasksAndZonesView({
|
||||
scaledWidth,
|
||||
scaledHeight,
|
||||
),
|
||||
distances: [],
|
||||
isFinished: true,
|
||||
color: [128, 128, 128],
|
||||
};
|
||||
@ -391,6 +398,7 @@ export default function MasksAndZonesView({
|
||||
setIsLoading={setIsLoading}
|
||||
onCancel={handleCancel}
|
||||
onSave={handleSave}
|
||||
setActiveLine={setActiveLine}
|
||||
/>
|
||||
)}
|
||||
{editPane == "motion_mask" && (
|
||||
@ -653,6 +661,7 @@ export default function MasksAndZonesView({
|
||||
activePolygonIndex={activePolygonIndex}
|
||||
hoveredPolygonIndex={hoveredPolygonIndex}
|
||||
selectedZoneMask={selectedZoneMask}
|
||||
activeLine={activeLine}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton className="size-full" />
|
||||
|
Loading…
Reference in New Issue
Block a user