robometrics 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- robometrics/__init__.py +85 -0
- robometrics/comfort.py +72 -0
- robometrics/evaluator.py +294 -0
- robometrics/geometry.py +164 -0
- robometrics/io.py +142 -0
- robometrics/physics.py +129 -0
- robometrics/prediction.py +59 -0
- robometrics/py.typed +1 -0
- robometrics/registry.py +419 -0
- robometrics/results.py +223 -0
- robometrics/safety.py +122 -0
- robometrics/schemas.py +85 -0
- robometrics/trajectory.py +114 -0
- robometrics-0.1.0.dist-info/METADATA +271 -0
- robometrics-0.1.0.dist-info/RECORD +18 -0
- robometrics-0.1.0.dist-info/WHEEL +5 -0
- robometrics-0.1.0.dist-info/licenses/LICENSE +21 -0
- robometrics-0.1.0.dist-info/top_level.txt +1 -0
robometrics/__init__.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""RoboMetrics: lightweight robotics metrics for Python."""
|
|
2
|
+
|
|
3
|
+
from robometrics.comfort import (
|
|
4
|
+
acceleration,
|
|
5
|
+
jerk,
|
|
6
|
+
jerk_cost,
|
|
7
|
+
max_acceleration,
|
|
8
|
+
max_deceleration,
|
|
9
|
+
smoothness_score,
|
|
10
|
+
)
|
|
11
|
+
from robometrics.evaluator import EvaluationInputError, Evaluator
|
|
12
|
+
from robometrics.io import TrajectoryIOError
|
|
13
|
+
from robometrics.physics import (
|
|
14
|
+
acceleration_limits_violated,
|
|
15
|
+
curvature_limits_violated,
|
|
16
|
+
dynamic_feasibility_score,
|
|
17
|
+
jerk_limits_violated,
|
|
18
|
+
speed_profile,
|
|
19
|
+
)
|
|
20
|
+
from robometrics.prediction import min_ade, min_fde, miss_rate, topk_trajectory_error
|
|
21
|
+
from robometrics.registry import MetricDefinition, MetricRegistry, UnknownMetricError, registry
|
|
22
|
+
from robometrics.results import EvaluationResult, MetricResult
|
|
23
|
+
from robometrics.safety import (
|
|
24
|
+
collision_rate,
|
|
25
|
+
lane_departure_rate,
|
|
26
|
+
min_distance_to_actors,
|
|
27
|
+
time_to_collision,
|
|
28
|
+
)
|
|
29
|
+
from robometrics.schemas import AgentState, Trajectory
|
|
30
|
+
from robometrics.trajectory import (
|
|
31
|
+
average_displacement_error,
|
|
32
|
+
curvature,
|
|
33
|
+
final_displacement_error,
|
|
34
|
+
hausdorff_distance,
|
|
35
|
+
lateral_error,
|
|
36
|
+
longitudinal_error,
|
|
37
|
+
path_length,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
ade = average_displacement_error
|
|
41
|
+
fde = final_displacement_error
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"AgentState",
|
|
45
|
+
"EvaluationInputError",
|
|
46
|
+
"EvaluationResult",
|
|
47
|
+
"Evaluator",
|
|
48
|
+
"MetricDefinition",
|
|
49
|
+
"MetricRegistry",
|
|
50
|
+
"MetricResult",
|
|
51
|
+
"Trajectory",
|
|
52
|
+
"TrajectoryIOError",
|
|
53
|
+
"UnknownMetricError",
|
|
54
|
+
"acceleration",
|
|
55
|
+
"acceleration_limits_violated",
|
|
56
|
+
"ade",
|
|
57
|
+
"average_displacement_error",
|
|
58
|
+
"collision_rate",
|
|
59
|
+
"curvature",
|
|
60
|
+
"curvature_limits_violated",
|
|
61
|
+
"dynamic_feasibility_score",
|
|
62
|
+
"fde",
|
|
63
|
+
"final_displacement_error",
|
|
64
|
+
"hausdorff_distance",
|
|
65
|
+
"jerk",
|
|
66
|
+
"jerk_cost",
|
|
67
|
+
"jerk_limits_violated",
|
|
68
|
+
"lane_departure_rate",
|
|
69
|
+
"lateral_error",
|
|
70
|
+
"longitudinal_error",
|
|
71
|
+
"max_acceleration",
|
|
72
|
+
"max_deceleration",
|
|
73
|
+
"min_ade",
|
|
74
|
+
"min_distance_to_actors",
|
|
75
|
+
"min_fde",
|
|
76
|
+
"miss_rate",
|
|
77
|
+
"path_length",
|
|
78
|
+
"registry",
|
|
79
|
+
"smoothness_score",
|
|
80
|
+
"speed_profile",
|
|
81
|
+
"time_to_collision",
|
|
82
|
+
"topk_trajectory_error",
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
__version__ = "0.1.0"
|
robometrics/comfort.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Planning and control smoothness metrics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from numpy.typing import ArrayLike
|
|
9
|
+
|
|
10
|
+
from robometrics.geometry import FloatArray, as_trajectory, validate_positive, vector_norms
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def acceleration(traj: ArrayLike, dt: float) -> FloatArray:
|
|
14
|
+
"""Return approximate acceleration vectors from a position trajectory."""
|
|
15
|
+
traj_arr = as_trajectory(traj, name="traj")
|
|
16
|
+
timestep = validate_positive(float(dt), name="dt")
|
|
17
|
+
if traj_arr.shape[0] < 3:
|
|
18
|
+
return np.zeros_like(traj_arr, dtype=np.float64)
|
|
19
|
+
velocity = _gradient(traj_arr, timestep)
|
|
20
|
+
return _gradient(velocity, timestep)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def jerk(traj: ArrayLike, dt: float) -> FloatArray:
|
|
24
|
+
"""Return approximate jerk vectors from a position trajectory."""
|
|
25
|
+
accel = acceleration(traj, dt)
|
|
26
|
+
if accel.shape[0] < 3:
|
|
27
|
+
return np.zeros_like(accel, dtype=np.float64)
|
|
28
|
+
return _gradient(accel, validate_positive(float(dt), name="dt"))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def jerk_cost(traj: ArrayLike, dt: float) -> float:
|
|
32
|
+
"""Return mean squared jerk magnitude."""
|
|
33
|
+
jerk_values = jerk(traj, dt)
|
|
34
|
+
return float(np.mean(np.square(vector_norms(jerk_values))))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def max_acceleration(traj: ArrayLike, dt: float) -> float:
|
|
38
|
+
"""Return maximum acceleration magnitude."""
|
|
39
|
+
accel = acceleration(traj, dt)
|
|
40
|
+
return float(np.max(vector_norms(accel)))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def max_deceleration(traj: ArrayLike, dt: float) -> float:
|
|
44
|
+
"""Return maximum longitudinal deceleration magnitude."""
|
|
45
|
+
traj_arr = as_trajectory(traj, name="traj")
|
|
46
|
+
timestep = validate_positive(float(dt), name="dt")
|
|
47
|
+
if traj_arr.shape[0] < 3:
|
|
48
|
+
return 0.0
|
|
49
|
+
|
|
50
|
+
velocity = _gradient(traj_arr, timestep)
|
|
51
|
+
accel = _gradient(velocity, timestep)
|
|
52
|
+
speeds = vector_norms(velocity)
|
|
53
|
+
moving = speeds > 1e-12
|
|
54
|
+
if not np.any(moving):
|
|
55
|
+
return 0.0
|
|
56
|
+
|
|
57
|
+
velocity_unit = np.zeros_like(velocity, dtype=np.float64)
|
|
58
|
+
velocity_unit[moving] = velocity[moving] / speeds[moving, None]
|
|
59
|
+
longitudinal_accel = np.sum(accel * velocity_unit, axis=1)
|
|
60
|
+
deceleration = np.maximum(-longitudinal_accel, 0.0)
|
|
61
|
+
return float(np.max(deceleration))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def smoothness_score(traj: ArrayLike, dt: float) -> float:
|
|
65
|
+
"""Return a bounded smoothness score where 1.0 is smoother and 0.0 is worse."""
|
|
66
|
+
return float(1.0 / (1.0 + jerk_cost(traj, dt)))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _gradient(values: FloatArray, dt: float) -> FloatArray:
|
|
70
|
+
edge_order: Literal[1, 2] = 2 if values.shape[0] > 2 else 1
|
|
71
|
+
gradient = np.gradient(values, dt, axis=0, edge_order=edge_order)
|
|
72
|
+
return np.asarray(gradient, dtype=np.float64)
|
robometrics/evaluator.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Lightweight local evaluator for named metric functions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from robometrics.registry import MetricDefinition, MetricRegistry, registry
|
|
11
|
+
from robometrics.results import EvaluationResult, MetricResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EvaluationInputError(ValueError):
|
|
15
|
+
"""Raised when an evaluation request cannot be constructed."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Evaluator:
|
|
19
|
+
"""Evaluate registered RoboMetrics metrics against local Python inputs."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, metric_registry: MetricRegistry | None = None) -> None:
|
|
22
|
+
self.registry = metric_registry or registry
|
|
23
|
+
|
|
24
|
+
def evaluate(
|
|
25
|
+
self,
|
|
26
|
+
*,
|
|
27
|
+
prediction: Any | None = None,
|
|
28
|
+
ground_truth: Any | None = None,
|
|
29
|
+
metrics: str | Sequence[str] | None = "all",
|
|
30
|
+
categories: str | Sequence[str] | None = None,
|
|
31
|
+
thresholds: Mapping[str, float] | None = None,
|
|
32
|
+
metric_kwargs: Mapping[str, Mapping[str, Any]] | None = None,
|
|
33
|
+
**inputs: Any,
|
|
34
|
+
) -> EvaluationResult:
|
|
35
|
+
"""Run selected metrics and return an EvaluationResult.
|
|
36
|
+
|
|
37
|
+
``prediction`` and ``ground_truth`` are mapped onto the common argument
|
|
38
|
+
names used by built-in metric functions. Additional metric-specific
|
|
39
|
+
inputs, such as ``dt`` or ``actor_trajs``, can be supplied as keyword
|
|
40
|
+
arguments.
|
|
41
|
+
"""
|
|
42
|
+
_validate_common_array(prediction, name="prediction", allowed_ndims=(2, 3))
|
|
43
|
+
_validate_common_array(ground_truth, name="ground_truth", allowed_ndims=(2,))
|
|
44
|
+
input_values = _build_inputs(
|
|
45
|
+
prediction=prediction,
|
|
46
|
+
ground_truth=ground_truth,
|
|
47
|
+
inputs=inputs,
|
|
48
|
+
)
|
|
49
|
+
if not input_values:
|
|
50
|
+
raise EvaluationInputError("at least one evaluation input is required")
|
|
51
|
+
|
|
52
|
+
selected, automatic_selection = self._select_metrics(metrics=metrics, categories=categories)
|
|
53
|
+
threshold_map = self._normalize_thresholds(thresholds)
|
|
54
|
+
metric_kwargs_map = self._normalize_metric_kwargs(metric_kwargs)
|
|
55
|
+
|
|
56
|
+
results: list[MetricResult] = []
|
|
57
|
+
skipped_metrics: list[str] = []
|
|
58
|
+
for metric in selected:
|
|
59
|
+
if not metric.is_compatible(input_values):
|
|
60
|
+
if automatic_selection:
|
|
61
|
+
skipped_metrics.append(metric.name)
|
|
62
|
+
continue
|
|
63
|
+
results.append(
|
|
64
|
+
_error_result(
|
|
65
|
+
metric,
|
|
66
|
+
"provided inputs are not compatible with this metric",
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
result = self._run_metric(metric, input_values, metric_kwargs_map)
|
|
73
|
+
except Exception as exc: # noqa: BLE001 - failures must be stored per metric.
|
|
74
|
+
result = _error_result(metric, str(exc), exc)
|
|
75
|
+
|
|
76
|
+
threshold = threshold_map.get(metric.name)
|
|
77
|
+
if threshold is not None and "error" not in result.metadata:
|
|
78
|
+
result.threshold = threshold
|
|
79
|
+
result.passed = bool(result.value <= threshold)
|
|
80
|
+
results.append(result)
|
|
81
|
+
|
|
82
|
+
if not results and selected:
|
|
83
|
+
raise EvaluationInputError("no compatible metrics found for the provided inputs")
|
|
84
|
+
|
|
85
|
+
return EvaluationResult(
|
|
86
|
+
results=results,
|
|
87
|
+
metadata={
|
|
88
|
+
"selected_metrics": [metric.name for metric in selected],
|
|
89
|
+
"skipped_metrics": skipped_metrics,
|
|
90
|
+
"categories": _category_list(categories),
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def _select_metrics(
|
|
95
|
+
self,
|
|
96
|
+
*,
|
|
97
|
+
metrics: str | Sequence[str] | None,
|
|
98
|
+
categories: str | Sequence[str] | None,
|
|
99
|
+
) -> tuple[list[MetricDefinition], bool]:
|
|
100
|
+
requested_categories = _category_list(categories)
|
|
101
|
+
if requested_categories:
|
|
102
|
+
known_categories = {category.lower() for category in self.registry.categories()}
|
|
103
|
+
unknown_categories = [
|
|
104
|
+
category
|
|
105
|
+
for category in requested_categories
|
|
106
|
+
if category.lower() not in known_categories
|
|
107
|
+
]
|
|
108
|
+
if unknown_categories:
|
|
109
|
+
raise EvaluationInputError(
|
|
110
|
+
"unknown metric categories: " + ", ".join(unknown_categories)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
automatic_selection = metrics is None or (
|
|
114
|
+
isinstance(metrics, str) and _normalize_name(metrics) == "all"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if automatic_selection:
|
|
118
|
+
selected = self.registry.list_metrics()
|
|
119
|
+
if requested_categories:
|
|
120
|
+
requested = {category.lower() for category in requested_categories}
|
|
121
|
+
selected = [metric for metric in selected if metric.category.lower() in requested]
|
|
122
|
+
return selected, True
|
|
123
|
+
|
|
124
|
+
requested_names = [metrics] if isinstance(metrics, str) else list(metrics or ())
|
|
125
|
+
|
|
126
|
+
selected = []
|
|
127
|
+
seen: set[str] = set()
|
|
128
|
+
for name in requested_names:
|
|
129
|
+
metric = self.registry.get(name)
|
|
130
|
+
if metric.name not in seen:
|
|
131
|
+
selected.append(metric)
|
|
132
|
+
seen.add(metric.name)
|
|
133
|
+
return selected, False
|
|
134
|
+
|
|
135
|
+
def _normalize_thresholds(
|
|
136
|
+
self,
|
|
137
|
+
thresholds: Mapping[str, float] | None,
|
|
138
|
+
) -> dict[str, float]:
|
|
139
|
+
normalized: dict[str, float] = {}
|
|
140
|
+
for name, threshold in (thresholds or {}).items():
|
|
141
|
+
metric = self.registry.get(name)
|
|
142
|
+
value = float(threshold)
|
|
143
|
+
if not np.isfinite(value):
|
|
144
|
+
raise EvaluationInputError(f"threshold for {name} must be finite")
|
|
145
|
+
normalized[metric.name] = value
|
|
146
|
+
return normalized
|
|
147
|
+
|
|
148
|
+
def _normalize_metric_kwargs(
|
|
149
|
+
self,
|
|
150
|
+
metric_kwargs: Mapping[str, Mapping[str, Any]] | None,
|
|
151
|
+
) -> dict[str, dict[str, Any]]:
|
|
152
|
+
normalized: dict[str, dict[str, Any]] = {}
|
|
153
|
+
for name, kwargs in (metric_kwargs or {}).items():
|
|
154
|
+
metric = self.registry.get(name)
|
|
155
|
+
normalized[metric.name] = dict(kwargs)
|
|
156
|
+
return normalized
|
|
157
|
+
|
|
158
|
+
def _run_metric(
|
|
159
|
+
self,
|
|
160
|
+
metric: MetricDefinition,
|
|
161
|
+
inputs: Mapping[str, Any],
|
|
162
|
+
metric_kwargs: Mapping[str, Mapping[str, Any]],
|
|
163
|
+
) -> MetricResult:
|
|
164
|
+
kwargs: dict[str, Any] = {key: inputs[key] for key in metric.required_inputs}
|
|
165
|
+
kwargs.update(metric.default_kwargs)
|
|
166
|
+
for key in metric.default_kwargs:
|
|
167
|
+
if key in inputs:
|
|
168
|
+
kwargs[key] = inputs[key]
|
|
169
|
+
kwargs.update(metric_kwargs.get(metric.name, {}))
|
|
170
|
+
|
|
171
|
+
raw_value = metric.fn(**kwargs)
|
|
172
|
+
return _result_from_raw(metric, raw_value)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _build_inputs(
|
|
176
|
+
*,
|
|
177
|
+
prediction: Any | None,
|
|
178
|
+
ground_truth: Any | None,
|
|
179
|
+
inputs: Mapping[str, Any],
|
|
180
|
+
) -> dict[str, Any]:
|
|
181
|
+
values = {key: value for key, value in inputs.items() if value is not None}
|
|
182
|
+
if prediction is not None:
|
|
183
|
+
values["prediction"] = prediction
|
|
184
|
+
for alias in ("pred", "predictions", "traj", "trajectory", "ego_traj"):
|
|
185
|
+
values.setdefault(alias, prediction)
|
|
186
|
+
if ground_truth is not None:
|
|
187
|
+
values["ground_truth"] = ground_truth
|
|
188
|
+
for alias in ("gt", "ref", "reference"):
|
|
189
|
+
values.setdefault(alias, ground_truth)
|
|
190
|
+
return values
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _validate_common_array(
|
|
194
|
+
value: Any | None,
|
|
195
|
+
*,
|
|
196
|
+
name: str,
|
|
197
|
+
allowed_ndims: tuple[int, ...],
|
|
198
|
+
) -> None:
|
|
199
|
+
if value is None:
|
|
200
|
+
return
|
|
201
|
+
try:
|
|
202
|
+
arr = np.asarray(value, dtype=np.float64)
|
|
203
|
+
except (TypeError, ValueError) as exc:
|
|
204
|
+
raise EvaluationInputError(f"{name} must be a numeric array-like value") from exc
|
|
205
|
+
|
|
206
|
+
if arr.ndim not in allowed_ndims:
|
|
207
|
+
allowed = " or ".join(str(ndim) for ndim in allowed_ndims)
|
|
208
|
+
raise EvaluationInputError(f"{name} must have {allowed} dimensions")
|
|
209
|
+
if arr.ndim == 2 and (arr.shape[0] == 0 or arr.shape[1] not in (2, 3)):
|
|
210
|
+
raise EvaluationInputError(f"{name} must be a non-empty Nx2 or Nx3 array")
|
|
211
|
+
if arr.ndim == 3 and (arr.shape[0] == 0 or arr.shape[1] == 0 or arr.shape[2] not in (2, 3)):
|
|
212
|
+
raise EvaluationInputError(f"{name} must be a non-empty KxTx2 or KxTx3 array")
|
|
213
|
+
if not np.all(np.isfinite(arr)):
|
|
214
|
+
raise EvaluationInputError(f"{name} must contain only finite values")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _result_from_raw(metric: MetricDefinition, raw_value: Any) -> MetricResult:
|
|
218
|
+
if isinstance(raw_value, MetricResult):
|
|
219
|
+
metadata = dict(raw_value.metadata)
|
|
220
|
+
metadata.setdefault("category", metric.category)
|
|
221
|
+
metadata.setdefault("description", metric.description)
|
|
222
|
+
return MetricResult(
|
|
223
|
+
name=metric.name,
|
|
224
|
+
value=raw_value.value,
|
|
225
|
+
unit=raw_value.unit or metric.unit,
|
|
226
|
+
passed=raw_value.passed,
|
|
227
|
+
threshold=raw_value.threshold,
|
|
228
|
+
metadata=metadata,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
value, metadata = _coerce_metric_value(raw_value)
|
|
232
|
+
metadata.setdefault("category", metric.category)
|
|
233
|
+
metadata.setdefault("description", metric.description)
|
|
234
|
+
return MetricResult(name=metric.name, value=value, unit=metric.unit, metadata=metadata)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _coerce_metric_value(raw_value: Any) -> tuple[float, dict[str, Any]]:
|
|
238
|
+
arr = np.asarray(raw_value, dtype=np.float64)
|
|
239
|
+
if arr.ndim == 0:
|
|
240
|
+
return float(arr), {}
|
|
241
|
+
if arr.size == 0:
|
|
242
|
+
return float("nan"), {"reduction": "empty", "raw_value": []}
|
|
243
|
+
|
|
244
|
+
if arr.ndim >= 2 and arr.shape[-1] in (2, 3):
|
|
245
|
+
reduced = np.linalg.norm(arr, axis=-1)
|
|
246
|
+
reduction = "mean_norm"
|
|
247
|
+
else:
|
|
248
|
+
reduced = arr
|
|
249
|
+
reduction = "mean"
|
|
250
|
+
|
|
251
|
+
finite_values = reduced[np.isfinite(reduced)]
|
|
252
|
+
value = float(np.mean(finite_values)) if finite_values.size else float("nan")
|
|
253
|
+
return (
|
|
254
|
+
value,
|
|
255
|
+
{
|
|
256
|
+
"reduction": reduction,
|
|
257
|
+
"shape": list(arr.shape),
|
|
258
|
+
"value_count": int(arr.size),
|
|
259
|
+
"raw_value": arr.tolist(),
|
|
260
|
+
},
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _error_result(
|
|
265
|
+
metric: MetricDefinition,
|
|
266
|
+
message: str,
|
|
267
|
+
exc: Exception | None = None,
|
|
268
|
+
) -> MetricResult:
|
|
269
|
+
metadata: dict[str, Any] = {
|
|
270
|
+
"category": metric.category,
|
|
271
|
+
"description": metric.description,
|
|
272
|
+
"error": message,
|
|
273
|
+
}
|
|
274
|
+
if exc is not None:
|
|
275
|
+
metadata["error_type"] = type(exc).__name__
|
|
276
|
+
return MetricResult(
|
|
277
|
+
name=metric.name,
|
|
278
|
+
value=float("nan"),
|
|
279
|
+
unit=metric.unit,
|
|
280
|
+
passed=False,
|
|
281
|
+
metadata=metadata,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _category_list(categories: str | Sequence[str] | None) -> list[str]:
|
|
286
|
+
if categories is None:
|
|
287
|
+
return []
|
|
288
|
+
if isinstance(categories, str):
|
|
289
|
+
return [categories]
|
|
290
|
+
return list(categories)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _normalize_name(name: str) -> str:
|
|
294
|
+
return name.strip().lower().replace("-", "_").replace(" ", "_")
|
robometrics/geometry.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Geometry helpers shared by RoboMetrics modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from typing import TypeAlias
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from numpy.typing import ArrayLike, NDArray
|
|
10
|
+
|
|
11
|
+
FloatArray: TypeAlias = NDArray[np.float64]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def as_trajectory(data: ArrayLike, *, name: str = "trajectory") -> FloatArray:
|
|
15
|
+
"""Return a finite non-empty Nx2 or Nx3 trajectory array."""
|
|
16
|
+
arr = np.asarray(data, dtype=np.float64)
|
|
17
|
+
if arr.ndim != 2 or arr.shape[1] not in (2, 3):
|
|
18
|
+
raise ValueError(f"{name} must be a Nx2 or Nx3 array")
|
|
19
|
+
if arr.shape[0] == 0:
|
|
20
|
+
raise ValueError(f"{name} must contain at least one point")
|
|
21
|
+
if not np.all(np.isfinite(arr)):
|
|
22
|
+
raise ValueError(f"{name} must contain only finite values")
|
|
23
|
+
return arr
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def as_prediction_set(data: ArrayLike, *, name: str = "predictions") -> FloatArray:
|
|
27
|
+
"""Return a finite non-empty KxTx2 or KxTx3 prediction array."""
|
|
28
|
+
arr = np.asarray(data, dtype=np.float64)
|
|
29
|
+
if arr.ndim != 3 or arr.shape[2] not in (2, 3):
|
|
30
|
+
raise ValueError(f"{name} must be a KxTx2 or KxTx3 array")
|
|
31
|
+
if arr.shape[0] == 0 or arr.shape[1] == 0:
|
|
32
|
+
raise ValueError(f"{name} must contain at least one trajectory and one timestep")
|
|
33
|
+
if not np.all(np.isfinite(arr)):
|
|
34
|
+
raise ValueError(f"{name} must contain only finite values")
|
|
35
|
+
return arr
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def require_same_shape(
|
|
39
|
+
left: FloatArray,
|
|
40
|
+
right: FloatArray,
|
|
41
|
+
left_name: str,
|
|
42
|
+
right_name: str,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Validate that two arrays have identical shape."""
|
|
45
|
+
if left.shape != right.shape:
|
|
46
|
+
raise ValueError(f"{left_name} and {right_name} must have the same shape")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def require_same_time_and_dim(
|
|
50
|
+
predictions: FloatArray,
|
|
51
|
+
ground_truth: FloatArray,
|
|
52
|
+
predictions_name: str = "predictions",
|
|
53
|
+
ground_truth_name: str = "gt",
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Validate that KxTxD predictions match TxD ground truth."""
|
|
56
|
+
if predictions.shape[1:] != ground_truth.shape:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
f"{predictions_name} timesteps/dimensions must match {ground_truth_name}"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def xy(data: FloatArray) -> FloatArray:
|
|
63
|
+
"""Return XY columns from an Nx2/Nx3 trajectory-like array."""
|
|
64
|
+
return data[:, :2]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def vector_norms(data: FloatArray) -> FloatArray:
|
|
68
|
+
"""Return row-wise Euclidean norms."""
|
|
69
|
+
return np.asarray(np.linalg.norm(data, axis=1), dtype=np.float64)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def pointwise_distances(left: FloatArray, right: FloatArray) -> FloatArray:
|
|
73
|
+
"""Return pointwise Euclidean distances for equally shaped trajectories."""
|
|
74
|
+
require_same_shape(left, right, "left", "right")
|
|
75
|
+
return vector_norms(left - right)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def validate_positive(value: float, *, name: str) -> float:
|
|
79
|
+
"""Return value if positive, otherwise raise ValueError."""
|
|
80
|
+
if not np.isfinite(value) or value <= 0.0:
|
|
81
|
+
raise ValueError(f"{name} must be a positive finite value")
|
|
82
|
+
return float(value)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def validate_nonnegative(value: float, *, name: str) -> float:
|
|
86
|
+
"""Return value if non-negative, otherwise raise ValueError."""
|
|
87
|
+
if not np.isfinite(value) or value < 0.0:
|
|
88
|
+
raise ValueError(f"{name} must be a non-negative finite value")
|
|
89
|
+
return float(value)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def as_actor_trajectories(actor_trajs: object) -> list[FloatArray]:
|
|
93
|
+
"""Normalize actor trajectories to a list of finite Nx2/Nx3 arrays."""
|
|
94
|
+
try:
|
|
95
|
+
arr = np.asarray(actor_trajs, dtype=np.float64)
|
|
96
|
+
except (TypeError, ValueError):
|
|
97
|
+
if not isinstance(actor_trajs, Iterable):
|
|
98
|
+
raise ValueError("actor_trajs must be an actor trajectory or iterable") from None
|
|
99
|
+
return [
|
|
100
|
+
as_trajectory(actor, name=f"actor_trajs[{index}]")
|
|
101
|
+
for index, actor in enumerate(actor_trajs)
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
if arr.size == 0:
|
|
105
|
+
return []
|
|
106
|
+
if arr.ndim == 2:
|
|
107
|
+
return [as_trajectory(arr, name="actor_trajs")]
|
|
108
|
+
if arr.ndim == 3:
|
|
109
|
+
return [
|
|
110
|
+
as_trajectory(arr[index], name=f"actor_trajs[{index}]")
|
|
111
|
+
for index in range(arr.shape[0])
|
|
112
|
+
]
|
|
113
|
+
raise ValueError("actor_trajs must be Nx2/Nx3, MxNx2/MxNx3, or an iterable of trajectories")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def point_in_polygon(point: FloatArray, polygon: FloatArray, *, tolerance: float = 1e-9) -> bool:
|
|
117
|
+
"""Return True when a 2D point lies inside or on the boundary of a polygon."""
|
|
118
|
+
px = float(point[0])
|
|
119
|
+
py = float(point[1])
|
|
120
|
+
poly = xy(polygon)
|
|
121
|
+
if poly.shape[0] < 3:
|
|
122
|
+
raise ValueError("lane_boundary must contain at least three polygon vertices")
|
|
123
|
+
|
|
124
|
+
inside = False
|
|
125
|
+
count = poly.shape[0]
|
|
126
|
+
for index in range(count):
|
|
127
|
+
start = poly[index]
|
|
128
|
+
end = poly[(index + 1) % count]
|
|
129
|
+
if _point_on_segment(np.array([px, py], dtype=np.float64), start, end, tolerance=tolerance):
|
|
130
|
+
return True
|
|
131
|
+
|
|
132
|
+
y_crosses = (start[1] > py) != (end[1] > py)
|
|
133
|
+
if y_crosses:
|
|
134
|
+
x_intersection = (end[0] - start[0]) * (py - start[1]) / (end[1] - start[1]) + start[0]
|
|
135
|
+
if px < x_intersection:
|
|
136
|
+
inside = not inside
|
|
137
|
+
return inside
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def points_in_polygon(points: FloatArray, polygon: FloatArray) -> NDArray[np.bool_]:
|
|
141
|
+
"""Return an inside-mask for points against a polygon."""
|
|
142
|
+
return np.asarray([point_in_polygon(point, polygon) for point in xy(points)], dtype=np.bool_)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _point_on_segment(
|
|
146
|
+
point: FloatArray,
|
|
147
|
+
start: FloatArray,
|
|
148
|
+
end: FloatArray,
|
|
149
|
+
*,
|
|
150
|
+
tolerance: float,
|
|
151
|
+
) -> bool:
|
|
152
|
+
segment = end - start
|
|
153
|
+
point_delta = point - start
|
|
154
|
+
squared_length = float(np.dot(segment, segment))
|
|
155
|
+
if squared_length <= tolerance * tolerance:
|
|
156
|
+
return bool(np.linalg.norm(point_delta) <= tolerance)
|
|
157
|
+
|
|
158
|
+
cross = abs(float(segment[0] * point_delta[1] - segment[1] * point_delta[0]))
|
|
159
|
+
if cross > tolerance:
|
|
160
|
+
return False
|
|
161
|
+
dot = float(np.dot(point_delta, segment))
|
|
162
|
+
if dot < -tolerance:
|
|
163
|
+
return False
|
|
164
|
+
return dot <= squared_length + tolerance
|