x4d-devkit 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.
- x4d_devkit/__init__.py +42 -0
- x4d_devkit/cli.py +131 -0
- x4d_devkit/client/__init__.py +3 -0
- x4d_devkit/client/client.py +56 -0
- x4d_devkit/client/clips.py +135 -0
- x4d_devkit/client/projects.py +25 -0
- x4d_devkit/converters/__init__.py +1 -0
- x4d_devkit/converters/nuscenes.py +715 -0
- x4d_devkit/core/__init__.py +28 -0
- x4d_devkit/core/loader.py +259 -0
- x4d_devkit/core/models.py +106 -0
- x4d_devkit/core/token.py +26 -0
- x4d_devkit/core/transform.py +159 -0
- x4d_devkit/eval/__init__.py +17 -0
- x4d_devkit/eval/detection.py +350 -0
- x4d_devkit/eval/matching.py +184 -0
- x4d_devkit/eval/metrics.py +83 -0
- x4d_devkit/validation/__init__.py +4 -0
- x4d_devkit/validation/report.py +46 -0
- x4d_devkit/validation/validator.py +236 -0
- x4d_devkit-0.1.0.dist-info/METADATA +117 -0
- x4d_devkit-0.1.0.dist-info/RECORD +26 -0
- x4d_devkit-0.1.0.dist-info/WHEEL +5 -0
- x4d_devkit-0.1.0.dist-info/entry_points.txt +2 -0
- x4d_devkit-0.1.0.dist-info/licenses/LICENSE +190 -0
- x4d_devkit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""3D object detection evaluation: mAP, TP metrics, NDS.
|
|
2
|
+
|
|
3
|
+
The evaluator is **not** tied to any fixed set of categories.
|
|
4
|
+
All class names, distance ranges, and category mappings are injected
|
|
5
|
+
via ``DetectionConfig``. Two ready-made presets are provided:
|
|
6
|
+
|
|
7
|
+
* ``nusc_detection_config()`` — nuScenes CVPR 2019 (10 classes)
|
|
8
|
+
* ``detzero_detection_config()`` — DetZero 3-class (Vehicle / Pedestrian / Cyclist)
|
|
9
|
+
|
|
10
|
+
Users can create their own ``DetectionConfig`` for any category set.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
from x4d_devkit.eval.matching import MetricData, accumulate
|
|
21
|
+
from x4d_devkit.eval.metrics import center_distance
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from x4d_devkit.core.loader import ClipLoader
|
|
25
|
+
|
|
26
|
+
TP_METRICS = ["trans_err", "scale_err", "orient_err", "vel_err", "attr_err"]
|
|
27
|
+
|
|
28
|
+
# ── Presets (convenience constants, NOT used by the evaluator directly) ──
|
|
29
|
+
|
|
30
|
+
NUSC_DETECTION_NAMES = [
|
|
31
|
+
"car", "truck", "bus", "trailer", "construction_vehicle",
|
|
32
|
+
"pedestrian", "motorcycle", "bicycle", "traffic_cone", "barrier",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
NUSC_CLASS_RANGE = {
|
|
36
|
+
"car": 50, "truck": 50, "bus": 50, "trailer": 50,
|
|
37
|
+
"construction_vehicle": 50, "pedestrian": 40, "motorcycle": 40,
|
|
38
|
+
"bicycle": 40, "traffic_cone": 30, "barrier": 30,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
NUSC_CATEGORY_MAP: dict[str, str] = {
|
|
42
|
+
"vehicle.car": "car",
|
|
43
|
+
"vehicle.truck": "truck",
|
|
44
|
+
"vehicle.bus.bendy": "bus",
|
|
45
|
+
"vehicle.bus.rigid": "bus",
|
|
46
|
+
"vehicle.trailer": "trailer",
|
|
47
|
+
"vehicle.construction": "construction_vehicle",
|
|
48
|
+
"human.pedestrian.adult": "pedestrian",
|
|
49
|
+
"human.pedestrian.child": "pedestrian",
|
|
50
|
+
"human.pedestrian.wheelchair": "pedestrian",
|
|
51
|
+
"human.pedestrian.stroller": "pedestrian",
|
|
52
|
+
"human.pedestrian.personal_mobility": "pedestrian",
|
|
53
|
+
"human.pedestrian.police_officer": "pedestrian",
|
|
54
|
+
"human.pedestrian.construction_worker": "pedestrian",
|
|
55
|
+
"vehicle.motorcycle": "motorcycle",
|
|
56
|
+
"vehicle.bicycle": "bicycle",
|
|
57
|
+
"movable_object.trafficcone": "traffic_cone",
|
|
58
|
+
"movable_object.barrier": "barrier",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
DETZERO_DETECTION_NAMES = ["Vehicle", "Pedestrian", "Cyclist"]
|
|
62
|
+
|
|
63
|
+
DETZERO_CLASS_RANGE = {
|
|
64
|
+
"Vehicle": 75, "Pedestrian": 50, "Cyclist": 50,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
DETZERO_CATEGORY_MAP: dict[str, str] = {
|
|
68
|
+
# nuScenes full names → DetZero classes
|
|
69
|
+
"vehicle.car": "Vehicle",
|
|
70
|
+
"vehicle.truck": "Vehicle",
|
|
71
|
+
"vehicle.bus.bendy": "Vehicle",
|
|
72
|
+
"vehicle.bus.rigid": "Vehicle",
|
|
73
|
+
"vehicle.trailer": "Vehicle",
|
|
74
|
+
"vehicle.construction": "Vehicle",
|
|
75
|
+
"human.pedestrian.adult": "Pedestrian",
|
|
76
|
+
"human.pedestrian.child": "Pedestrian",
|
|
77
|
+
"human.pedestrian.wheelchair": "Pedestrian",
|
|
78
|
+
"human.pedestrian.stroller": "Pedestrian",
|
|
79
|
+
"human.pedestrian.personal_mobility": "Pedestrian",
|
|
80
|
+
"human.pedestrian.police_officer": "Pedestrian",
|
|
81
|
+
"human.pedestrian.construction_worker": "Pedestrian",
|
|
82
|
+
"vehicle.motorcycle": "Cyclist",
|
|
83
|
+
"vehicle.bicycle": "Cyclist",
|
|
84
|
+
# Short names
|
|
85
|
+
"car": "Vehicle", "truck": "Vehicle", "bus": "Vehicle",
|
|
86
|
+
"trailer": "Vehicle", "construction_vehicle": "Vehicle",
|
|
87
|
+
"pedestrian": "Pedestrian",
|
|
88
|
+
"motorcycle": "Cyclist", "bicycle": "Cyclist",
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
# Backwards-compatible aliases
|
|
92
|
+
DETECTION_NAMES = NUSC_DETECTION_NAMES
|
|
93
|
+
CLASS_RANGE = NUSC_CLASS_RANGE
|
|
94
|
+
CATEGORY_MAP = NUSC_CATEGORY_MAP
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def map_category(
|
|
98
|
+
raw_category: str,
|
|
99
|
+
category_map: dict[str, str] | None = None,
|
|
100
|
+
class_names: list[str] | None = None,
|
|
101
|
+
) -> str | None:
|
|
102
|
+
"""Map a raw annotation category to a detection class name.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
raw_category: The raw category string from the annotation.
|
|
106
|
+
category_map: Mapping from raw names to detection class names.
|
|
107
|
+
Falls back to ``NUSC_CATEGORY_MAP`` if *None*.
|
|
108
|
+
class_names: Set of valid detection class names.
|
|
109
|
+
If the raw category is already a valid class name, it is
|
|
110
|
+
returned as-is. Falls back to ``NUSC_DETECTION_NAMES``
|
|
111
|
+
if *None*.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Detection class name, or *None* if unrecognized.
|
|
115
|
+
"""
|
|
116
|
+
if category_map is None:
|
|
117
|
+
category_map = NUSC_CATEGORY_MAP
|
|
118
|
+
if class_names is None:
|
|
119
|
+
class_names = NUSC_DETECTION_NAMES
|
|
120
|
+
|
|
121
|
+
if raw_category in category_map:
|
|
122
|
+
return category_map[raw_category]
|
|
123
|
+
if raw_category in class_names:
|
|
124
|
+
return raw_category
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class DetectionConfig:
|
|
130
|
+
"""Evaluation configuration — fully user-controlled.
|
|
131
|
+
|
|
132
|
+
All fields (class names, distance ranges, category mapping) are
|
|
133
|
+
injected here. Nothing is hardcoded in the evaluator.
|
|
134
|
+
"""
|
|
135
|
+
class_names: list[str]
|
|
136
|
+
dist_thresholds: list[float]
|
|
137
|
+
dist_th_tp: float
|
|
138
|
+
min_recall: float
|
|
139
|
+
min_precision: float
|
|
140
|
+
max_boxes_per_sample: int
|
|
141
|
+
class_range: dict[str, float]
|
|
142
|
+
category_map: dict[str, str] = field(default_factory=dict)
|
|
143
|
+
num_recall_points: int = 101
|
|
144
|
+
mean_ap_weight: int = 5
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def nusc_detection_config() -> DetectionConfig:
|
|
148
|
+
"""Return nuScenes CVPR2019 standard detection evaluation config (10 classes)."""
|
|
149
|
+
return DetectionConfig(
|
|
150
|
+
class_names=list(NUSC_DETECTION_NAMES),
|
|
151
|
+
dist_thresholds=[0.5, 1.0, 2.0, 4.0],
|
|
152
|
+
dist_th_tp=2.0,
|
|
153
|
+
min_recall=0.1,
|
|
154
|
+
min_precision=0.1,
|
|
155
|
+
max_boxes_per_sample=500,
|
|
156
|
+
class_range=dict(NUSC_CLASS_RANGE),
|
|
157
|
+
category_map=dict(NUSC_CATEGORY_MAP),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def detzero_detection_config() -> DetectionConfig:
|
|
162
|
+
"""Return DetZero 3-class detection evaluation config."""
|
|
163
|
+
return DetectionConfig(
|
|
164
|
+
class_names=list(DETZERO_DETECTION_NAMES),
|
|
165
|
+
dist_thresholds=[0.5, 1.0, 2.0, 4.0],
|
|
166
|
+
dist_th_tp=2.0,
|
|
167
|
+
min_recall=0.1,
|
|
168
|
+
min_precision=0.1,
|
|
169
|
+
max_boxes_per_sample=500,
|
|
170
|
+
class_range=dict(DETZERO_CLASS_RANGE),
|
|
171
|
+
category_map=dict(DETZERO_CATEGORY_MAP),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass
|
|
176
|
+
class DetectionResult:
|
|
177
|
+
mean_ap: float
|
|
178
|
+
nd_score: float
|
|
179
|
+
label_aps: dict[str, dict[str, float]]
|
|
180
|
+
mean_dist_aps: dict[str, float]
|
|
181
|
+
tp_errors: dict[str, float]
|
|
182
|
+
label_tp_errors: dict[str, dict[str, float]]
|
|
183
|
+
tp_scores: dict[str, float]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _calc_ap(md: MetricData, min_recall: float, min_precision: float) -> float:
|
|
187
|
+
"""Calculate Average Precision from MetricData."""
|
|
188
|
+
prec = np.copy(md.precision)
|
|
189
|
+
prec = prec[round(100 * min_recall) + 1:]
|
|
190
|
+
prec -= min_precision
|
|
191
|
+
prec[prec < 0] = 0
|
|
192
|
+
if len(prec) == 0:
|
|
193
|
+
return 0.0
|
|
194
|
+
return float(np.mean(prec) / (1.0 - min_precision))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _calc_tp(md: MetricData, min_recall: float, metric_name: str) -> float:
|
|
198
|
+
"""Calculate mean TP metric between min_recall and max_recall."""
|
|
199
|
+
first_ind = round(100 * min_recall) + 1
|
|
200
|
+
last_ind = md.max_recall_ind
|
|
201
|
+
if last_ind < first_ind:
|
|
202
|
+
return 1.0
|
|
203
|
+
metric_arr = getattr(md, metric_name)
|
|
204
|
+
return float(np.nanmean(metric_arr[first_ind:last_ind + 1]))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _extract_boxes(
|
|
208
|
+
clip: ClipLoader,
|
|
209
|
+
config: DetectionConfig,
|
|
210
|
+
max_boxes: int | None = None,
|
|
211
|
+
) -> dict[str, list[dict]]:
|
|
212
|
+
"""Extract annotation boxes grouped by sample_token, with distance filtering."""
|
|
213
|
+
ep_by_token = {ep.token: ep for ep in clip.ego_poses}
|
|
214
|
+
category_map = config.category_map or {}
|
|
215
|
+
class_names = config.class_names
|
|
216
|
+
class_range = config.class_range
|
|
217
|
+
|
|
218
|
+
# Map sample_token → ego position (for distance filtering)
|
|
219
|
+
sample_ego_pos: dict[str, np.ndarray] = {}
|
|
220
|
+
for sd in clip.sample_data:
|
|
221
|
+
if sd.sample_token not in sample_ego_pos and sd.is_keyframe:
|
|
222
|
+
ep = ep_by_token.get(sd.ego_pose_token)
|
|
223
|
+
if ep is not None:
|
|
224
|
+
sample_ego_pos[sd.sample_token] = ep.translation
|
|
225
|
+
|
|
226
|
+
result: dict[str, list[dict]] = {}
|
|
227
|
+
for sample in clip.samples:
|
|
228
|
+
ego_pos = sample_ego_pos.get(sample.token)
|
|
229
|
+
anns = clip.annotations_for_sample(sample.token)
|
|
230
|
+
boxes = []
|
|
231
|
+
for ann in anns:
|
|
232
|
+
det_name = map_category(ann.category, category_map, class_names)
|
|
233
|
+
if det_name is None or det_name not in class_range:
|
|
234
|
+
continue
|
|
235
|
+
if ego_pos is not None:
|
|
236
|
+
dist = float(np.linalg.norm(ann.translation[:2] - ego_pos[:2]))
|
|
237
|
+
if dist > class_range[det_name]:
|
|
238
|
+
continue
|
|
239
|
+
boxes.append({
|
|
240
|
+
"translation": ann.translation,
|
|
241
|
+
"size": ann.size,
|
|
242
|
+
"rotation": ann.rotation,
|
|
243
|
+
"category": det_name,
|
|
244
|
+
"score": ann.score,
|
|
245
|
+
"velocity": ann.velocity,
|
|
246
|
+
"attributes": ann.attributes,
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
if max_boxes is not None and len(boxes) > max_boxes:
|
|
250
|
+
boxes.sort(key=lambda b: b["score"] or 0, reverse=True)
|
|
251
|
+
boxes = boxes[:max_boxes]
|
|
252
|
+
|
|
253
|
+
result[sample.token] = boxes
|
|
254
|
+
|
|
255
|
+
return result
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class DetectionEval:
|
|
259
|
+
"""3D object detection evaluator."""
|
|
260
|
+
|
|
261
|
+
def __init__(
|
|
262
|
+
self,
|
|
263
|
+
gt_clips: list[ClipLoader],
|
|
264
|
+
pred_clips: list[ClipLoader],
|
|
265
|
+
config: DetectionConfig | None = None,
|
|
266
|
+
) -> None:
|
|
267
|
+
self._gt_clips = gt_clips
|
|
268
|
+
self._pred_clips = pred_clips
|
|
269
|
+
self._config = config or nusc_detection_config()
|
|
270
|
+
|
|
271
|
+
def evaluate(self) -> DetectionResult:
|
|
272
|
+
cfg = self._config
|
|
273
|
+
|
|
274
|
+
gt_by_id = {c.meta.clip_id: c for c in self._gt_clips}
|
|
275
|
+
pred_by_id = {c.meta.clip_id: c for c in self._pred_clips}
|
|
276
|
+
|
|
277
|
+
all_gt: dict[str, list[dict]] = {}
|
|
278
|
+
all_pred: dict[str, list[dict]] = {}
|
|
279
|
+
|
|
280
|
+
for clip_id, gt_clip in gt_by_id.items():
|
|
281
|
+
gt_boxes = _extract_boxes(gt_clip, cfg)
|
|
282
|
+
for st, boxes in gt_boxes.items():
|
|
283
|
+
all_gt[st] = boxes
|
|
284
|
+
|
|
285
|
+
pred_clip = pred_by_id.get(clip_id)
|
|
286
|
+
if pred_clip is not None:
|
|
287
|
+
pred_boxes = _extract_boxes(pred_clip, cfg, max_boxes=cfg.max_boxes_per_sample)
|
|
288
|
+
for st, boxes in pred_boxes.items():
|
|
289
|
+
all_pred[st] = boxes
|
|
290
|
+
|
|
291
|
+
for st in all_gt:
|
|
292
|
+
if st not in all_pred:
|
|
293
|
+
all_pred[st] = []
|
|
294
|
+
|
|
295
|
+
# Accumulate per class per distance threshold
|
|
296
|
+
metric_data: dict[str, dict[str, MetricData]] = {}
|
|
297
|
+
for class_name in cfg.class_names:
|
|
298
|
+
metric_data[class_name] = {}
|
|
299
|
+
for dist_th in cfg.dist_thresholds:
|
|
300
|
+
metric_data[class_name][str(dist_th)] = accumulate(
|
|
301
|
+
all_gt, all_pred, class_name, dist_th
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
# AP per class per threshold
|
|
305
|
+
label_aps: dict[str, dict[str, float]] = {}
|
|
306
|
+
for class_name in cfg.class_names:
|
|
307
|
+
label_aps[class_name] = {}
|
|
308
|
+
for dist_th in cfg.dist_thresholds:
|
|
309
|
+
md = metric_data[class_name][str(dist_th)]
|
|
310
|
+
label_aps[class_name][str(dist_th)] = _calc_ap(md, cfg.min_recall, cfg.min_precision)
|
|
311
|
+
|
|
312
|
+
mean_dist_aps = {
|
|
313
|
+
cn: float(np.mean(list(label_aps[cn].values())))
|
|
314
|
+
for cn in cfg.class_names
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
all_ap_values = [ap for cn_aps in label_aps.values() for ap in cn_aps.values()]
|
|
318
|
+
mean_ap = float(np.mean(all_ap_values)) if all_ap_values else 0.0
|
|
319
|
+
|
|
320
|
+
# TP errors per class at dist_th_tp
|
|
321
|
+
label_tp_errors: dict[str, dict[str, float]] = {}
|
|
322
|
+
for class_name in cfg.class_names:
|
|
323
|
+
md = metric_data[class_name][str(cfg.dist_th_tp)]
|
|
324
|
+
label_tp_errors[class_name] = {}
|
|
325
|
+
for metric_name in TP_METRICS:
|
|
326
|
+
if class_name == "traffic_cone" and metric_name in ("attr_err", "vel_err", "orient_err"):
|
|
327
|
+
label_tp_errors[class_name][metric_name] = float("nan")
|
|
328
|
+
elif class_name == "barrier" and metric_name in ("attr_err", "vel_err"):
|
|
329
|
+
label_tp_errors[class_name][metric_name] = float("nan")
|
|
330
|
+
else:
|
|
331
|
+
label_tp_errors[class_name][metric_name] = _calc_tp(md, cfg.min_recall, metric_name)
|
|
332
|
+
|
|
333
|
+
tp_errors = {}
|
|
334
|
+
for metric_name in TP_METRICS:
|
|
335
|
+
values = [label_tp_errors[cn][metric_name] for cn in cfg.class_names]
|
|
336
|
+
tp_errors[metric_name] = float(np.nanmean(values))
|
|
337
|
+
|
|
338
|
+
tp_scores = {m: max(0.0, 1.0 - tp_errors[m]) for m in TP_METRICS}
|
|
339
|
+
|
|
340
|
+
nd_score = (cfg.mean_ap_weight * mean_ap + sum(tp_scores.values())) / (cfg.mean_ap_weight + len(tp_scores))
|
|
341
|
+
|
|
342
|
+
return DetectionResult(
|
|
343
|
+
mean_ap=mean_ap,
|
|
344
|
+
nd_score=nd_score,
|
|
345
|
+
label_aps=label_aps,
|
|
346
|
+
mean_dist_aps=mean_dist_aps,
|
|
347
|
+
tp_errors=tp_errors,
|
|
348
|
+
label_tp_errors=label_tp_errors,
|
|
349
|
+
tp_scores=tp_scores,
|
|
350
|
+
)
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# x4d_devkit/eval/matching.py
|
|
2
|
+
"""Detection matching: accumulate GT/Pred pairs into precision-recall curves."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from x4d_devkit.eval.metrics import (
|
|
11
|
+
center_distance,
|
|
12
|
+
scale_iou,
|
|
13
|
+
yaw_diff,
|
|
14
|
+
attr_acc,
|
|
15
|
+
cummean,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
NELEM = 101 # Number of recall interpolation points
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class MetricData:
|
|
23
|
+
"""Interpolated precision, recall, and TP error curves (101 points)."""
|
|
24
|
+
|
|
25
|
+
recall: np.ndarray # (101,)
|
|
26
|
+
precision: np.ndarray # (101,)
|
|
27
|
+
confidence: np.ndarray # (101,)
|
|
28
|
+
trans_err: np.ndarray # (101,)
|
|
29
|
+
scale_err: np.ndarray # (101,)
|
|
30
|
+
orient_err: np.ndarray # (101,)
|
|
31
|
+
vel_err: np.ndarray # (101,)
|
|
32
|
+
attr_err: np.ndarray # (101,)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def max_recall_ind(self) -> int:
|
|
36
|
+
"""Index of last non-zero confidence (= max achieved recall point)."""
|
|
37
|
+
non_zero = np.nonzero(self.confidence)[0]
|
|
38
|
+
if len(non_zero) == 0:
|
|
39
|
+
return 0
|
|
40
|
+
return int(non_zero[-1])
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def no_predictions(cls) -> MetricData:
|
|
44
|
+
"""Return a zero-valued MetricData for when there are no predictions."""
|
|
45
|
+
return cls(
|
|
46
|
+
recall=np.linspace(0, 1, NELEM),
|
|
47
|
+
precision=np.zeros(NELEM),
|
|
48
|
+
confidence=np.zeros(NELEM),
|
|
49
|
+
trans_err=np.ones(NELEM),
|
|
50
|
+
scale_err=np.ones(NELEM),
|
|
51
|
+
orient_err=np.ones(NELEM),
|
|
52
|
+
vel_err=np.ones(NELEM),
|
|
53
|
+
attr_err=np.ones(NELEM),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def accumulate(
|
|
58
|
+
gt_boxes: dict[str, list[dict]],
|
|
59
|
+
pred_boxes: dict[str, list[dict]],
|
|
60
|
+
class_name: str,
|
|
61
|
+
dist_th: float,
|
|
62
|
+
) -> MetricData:
|
|
63
|
+
"""Match predictions to ground truth and compute interpolated curves.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
gt_boxes: {sample_token: [box_dict, ...]} — all GT boxes across samples.
|
|
67
|
+
pred_boxes: {sample_token: [box_dict, ...]} — all pred boxes across samples.
|
|
68
|
+
class_name: Class to evaluate (e.g. "car").
|
|
69
|
+
dist_th: Center distance threshold for matching.
|
|
70
|
+
|
|
71
|
+
Each box_dict has: translation (3,), size (3,), rotation (4,) [qx,qy,qz,qw],
|
|
72
|
+
category (str), score (float, pred only), velocity (3,) or None, attributes (list[str]).
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
MetricData with interpolated curves at 101 recall points.
|
|
76
|
+
"""
|
|
77
|
+
# Filter by class
|
|
78
|
+
gt_by_sample: dict[str, list[dict]] = {}
|
|
79
|
+
npos = 0
|
|
80
|
+
for sample_token, boxes in gt_boxes.items():
|
|
81
|
+
class_boxes = [b for b in boxes if b["category"] == class_name]
|
|
82
|
+
gt_by_sample[sample_token] = class_boxes
|
|
83
|
+
npos += len(class_boxes)
|
|
84
|
+
|
|
85
|
+
pred_by_sample: dict[str, list[dict]] = {}
|
|
86
|
+
all_preds = []
|
|
87
|
+
for sample_token, boxes in pred_boxes.items():
|
|
88
|
+
class_boxes = [b for b in boxes if b["category"] == class_name]
|
|
89
|
+
class_boxes.sort(key=lambda b: b["score"], reverse=True)
|
|
90
|
+
pred_by_sample[sample_token] = class_boxes
|
|
91
|
+
for i, b in enumerate(class_boxes):
|
|
92
|
+
all_preds.append((b["score"], sample_token, i))
|
|
93
|
+
|
|
94
|
+
if npos == 0:
|
|
95
|
+
return MetricData.no_predictions()
|
|
96
|
+
|
|
97
|
+
# Sort all predictions globally by score descending
|
|
98
|
+
all_preds.sort(key=lambda x: x[0], reverse=True)
|
|
99
|
+
|
|
100
|
+
# Greedy matching
|
|
101
|
+
tp_list = []
|
|
102
|
+
conf_list = []
|
|
103
|
+
match_data = {k: [] for k in ["trans_err", "scale_err", "orient_err", "vel_err", "attr_err"]}
|
|
104
|
+
matched_gt: dict[str, set[int]] = {st: set() for st in gt_by_sample}
|
|
105
|
+
|
|
106
|
+
for score, sample_token, pred_idx in all_preds:
|
|
107
|
+
pred = pred_by_sample[sample_token][pred_idx]
|
|
108
|
+
gt_list = gt_by_sample.get(sample_token, [])
|
|
109
|
+
|
|
110
|
+
min_dist = float("inf")
|
|
111
|
+
min_gt_idx = -1
|
|
112
|
+
for gt_idx, gt in enumerate(gt_list):
|
|
113
|
+
if gt_idx in matched_gt.get(sample_token, set()):
|
|
114
|
+
continue
|
|
115
|
+
d = center_distance(pred["translation"], gt["translation"])
|
|
116
|
+
if d < min_dist:
|
|
117
|
+
min_dist = d
|
|
118
|
+
min_gt_idx = gt_idx
|
|
119
|
+
|
|
120
|
+
is_tp = min_dist < dist_th and min_gt_idx >= 0
|
|
121
|
+
tp_list.append(1 if is_tp else 0)
|
|
122
|
+
conf_list.append(score)
|
|
123
|
+
|
|
124
|
+
if is_tp:
|
|
125
|
+
matched_gt[sample_token].add(min_gt_idx)
|
|
126
|
+
gt = gt_list[min_gt_idx]
|
|
127
|
+
match_data["trans_err"].append(center_distance(pred["translation"], gt["translation"]))
|
|
128
|
+
match_data["scale_err"].append(1.0 - scale_iou(pred["size"], gt["size"]))
|
|
129
|
+
period = np.pi if class_name == "barrier" else 2 * np.pi
|
|
130
|
+
match_data["orient_err"].append(yaw_diff(pred["rotation"], gt["rotation"], period=period))
|
|
131
|
+
if pred["velocity"] is not None and gt["velocity"] is not None:
|
|
132
|
+
vel_diff = float(np.linalg.norm(
|
|
133
|
+
np.asarray(pred["velocity"])[:2] - np.asarray(gt["velocity"])[:2]
|
|
134
|
+
))
|
|
135
|
+
match_data["vel_err"].append(vel_diff)
|
|
136
|
+
else:
|
|
137
|
+
match_data["vel_err"].append(float("nan"))
|
|
138
|
+
match_data["attr_err"].append(1.0 - attr_acc(gt["attributes"], pred["attributes"]))
|
|
139
|
+
else:
|
|
140
|
+
for key in match_data:
|
|
141
|
+
match_data[key].append(float("nan"))
|
|
142
|
+
|
|
143
|
+
# Compute precision / recall
|
|
144
|
+
tp_arr = np.array(tp_list, dtype=float)
|
|
145
|
+
conf_arr = np.array(conf_list, dtype=float)
|
|
146
|
+
tp_cum = np.cumsum(tp_arr)
|
|
147
|
+
fp_cum = np.cumsum(1 - tp_arr)
|
|
148
|
+
precision_raw = tp_cum / (tp_cum + fp_cum)
|
|
149
|
+
recall_raw = tp_cum / npos
|
|
150
|
+
|
|
151
|
+
# Interpolate to NELEM recall points
|
|
152
|
+
recall_interp = np.linspace(0, 1, NELEM)
|
|
153
|
+
precision_interp = np.zeros(NELEM)
|
|
154
|
+
confidence_interp = np.zeros(NELEM)
|
|
155
|
+
|
|
156
|
+
for i, r in enumerate(recall_interp):
|
|
157
|
+
mask = recall_raw >= r - 1e-10
|
|
158
|
+
if mask.any():
|
|
159
|
+
precision_interp[i] = precision_raw[mask].max()
|
|
160
|
+
confidence_interp[i] = conf_arr[mask].min()
|
|
161
|
+
|
|
162
|
+
# Interpolate TP errors using cumulative mean
|
|
163
|
+
tp_metric_interp = {}
|
|
164
|
+
for key in match_data:
|
|
165
|
+
raw = np.array(match_data[key], dtype=float)
|
|
166
|
+
cm = cummean(raw)
|
|
167
|
+
interp = np.ones(NELEM)
|
|
168
|
+
for i, r in enumerate(recall_interp):
|
|
169
|
+
mask = recall_raw >= r - 1e-10
|
|
170
|
+
if mask.any():
|
|
171
|
+
idx = np.where(mask)[0][0]
|
|
172
|
+
interp[i] = cm[idx]
|
|
173
|
+
tp_metric_interp[key] = interp
|
|
174
|
+
|
|
175
|
+
return MetricData(
|
|
176
|
+
recall=recall_interp,
|
|
177
|
+
precision=precision_interp,
|
|
178
|
+
confidence=confidence_interp,
|
|
179
|
+
trans_err=tp_metric_interp["trans_err"],
|
|
180
|
+
scale_err=tp_metric_interp["scale_err"],
|
|
181
|
+
orient_err=tp_metric_interp["orient_err"],
|
|
182
|
+
vel_err=tp_metric_interp["vel_err"],
|
|
183
|
+
attr_err=tp_metric_interp["attr_err"],
|
|
184
|
+
)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Pure metric functions for detection evaluation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from x4d_devkit.core.transform import quat_to_rotation_matrix
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def center_distance(
|
|
11
|
+
translation_a: list[float] | np.ndarray,
|
|
12
|
+
translation_b: list[float] | np.ndarray,
|
|
13
|
+
) -> float:
|
|
14
|
+
"""L2 distance between two 3D points in the XY plane (Z ignored)."""
|
|
15
|
+
a = np.asarray(translation_a, dtype=float)
|
|
16
|
+
b = np.asarray(translation_b, dtype=float)
|
|
17
|
+
return float(np.linalg.norm(a[:2] - b[:2]))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def scale_iou(
|
|
21
|
+
size_a: list[float] | np.ndarray,
|
|
22
|
+
size_b: list[float] | np.ndarray,
|
|
23
|
+
) -> float:
|
|
24
|
+
"""Axis-aligned scale IoU between two 3D box sizes [l, w, h]."""
|
|
25
|
+
sa = np.asarray(size_a, dtype=float)
|
|
26
|
+
sb = np.asarray(size_b, dtype=float)
|
|
27
|
+
min_s = np.minimum(sa, sb)
|
|
28
|
+
intersection = float(np.prod(min_s))
|
|
29
|
+
vol_a = float(np.prod(sa))
|
|
30
|
+
vol_b = float(np.prod(sb))
|
|
31
|
+
union = vol_a + vol_b - intersection
|
|
32
|
+
if union <= 0:
|
|
33
|
+
return 0.0
|
|
34
|
+
return intersection / union
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def quaternion_yaw(quat: list[float] | np.ndarray) -> float:
|
|
38
|
+
"""Extract yaw angle from quaternion [qx, qy, qz, qw]."""
|
|
39
|
+
qx, qy, qz, qw = quat[0], quat[1], quat[2], quat[3]
|
|
40
|
+
R = quat_to_rotation_matrix(qx, qy, qz, qw)
|
|
41
|
+
v = R @ np.array([1.0, 0.0, 0.0])
|
|
42
|
+
return float(np.arctan2(v[1], v[0]))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _angle_diff(x: float, y: float, period: float) -> float:
|
|
46
|
+
"""Smallest signed angle difference, respecting periodicity."""
|
|
47
|
+
diff = (x - y + period / 2) % period - period / 2
|
|
48
|
+
if diff > np.pi:
|
|
49
|
+
diff -= period
|
|
50
|
+
return diff
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def yaw_diff(
|
|
54
|
+
quat_a: list[float] | np.ndarray,
|
|
55
|
+
quat_b: list[float] | np.ndarray,
|
|
56
|
+
period: float = 2 * np.pi,
|
|
57
|
+
) -> float:
|
|
58
|
+
"""Absolute yaw angle difference between two quaternions [qx,qy,qz,qw]."""
|
|
59
|
+
yaw_a = quaternion_yaw(quat_a)
|
|
60
|
+
yaw_b = quaternion_yaw(quat_b)
|
|
61
|
+
return abs(_angle_diff(yaw_a, yaw_b, period))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def attr_acc(
|
|
65
|
+
gt_attrs: list[str],
|
|
66
|
+
pred_attrs: list[str],
|
|
67
|
+
) -> float:
|
|
68
|
+
"""Attribute classification accuracy. Returns NaN if either is empty."""
|
|
69
|
+
if not gt_attrs or not pred_attrs:
|
|
70
|
+
return float("nan")
|
|
71
|
+
return 1.0 if gt_attrs[0] == pred_attrs[0] else 0.0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def cummean(arr: np.ndarray) -> np.ndarray:
|
|
75
|
+
"""NaN-aware cumulative mean. Returns ones if all NaN."""
|
|
76
|
+
mask = ~np.isnan(arr)
|
|
77
|
+
count = np.cumsum(mask).astype(float)
|
|
78
|
+
count[count == 0] = 1.0
|
|
79
|
+
filled = np.where(mask, arr, 0.0)
|
|
80
|
+
result = np.cumsum(filled) / count
|
|
81
|
+
if not mask.any():
|
|
82
|
+
return np.ones_like(arr)
|
|
83
|
+
return result
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Validation report dataclass."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class ValidationIssue:
|
|
9
|
+
"""A single validation issue."""
|
|
10
|
+
|
|
11
|
+
check: str
|
|
12
|
+
level: str # "error" or "warning"
|
|
13
|
+
message: str
|
|
14
|
+
detail: str = ""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ValidationReport:
|
|
19
|
+
"""Result of validating a clip directory."""
|
|
20
|
+
|
|
21
|
+
clip_id: str
|
|
22
|
+
issues: list[ValidationIssue] = field(default_factory=list)
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def ok(self) -> bool:
|
|
26
|
+
return not any(i.level == "error" for i in self.issues)
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def errors(self) -> list[ValidationIssue]:
|
|
30
|
+
return [i for i in self.issues if i.level == "error"]
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def warnings(self) -> list[ValidationIssue]:
|
|
34
|
+
return [i for i in self.issues if i.level == "warning"]
|
|
35
|
+
|
|
36
|
+
def __str__(self) -> str:
|
|
37
|
+
status = "PASS" if self.ok else "FAIL"
|
|
38
|
+
lines = [
|
|
39
|
+
f"[{status}] {self.clip_id}: {len(self.errors)} errors, {len(self.warnings)} warnings"
|
|
40
|
+
]
|
|
41
|
+
for issue in self.issues:
|
|
42
|
+
prefix = "ERROR" if issue.level == "error" else "WARN"
|
|
43
|
+
lines.append(f" [{prefix}] {issue.check}: {issue.message}")
|
|
44
|
+
if issue.detail:
|
|
45
|
+
lines.append(f" {issue.detail}")
|
|
46
|
+
return "\n".join(lines)
|