x4d-devkit 0.7.0__tar.gz → 0.8.0__tar.gz
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-0.7.0 → x4d_devkit-0.8.0}/PKG-INFO +1 -1
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/pyproject.toml +1 -1
- x4d_devkit-0.8.0/tests/test_box.py +121 -0
- x4d_devkit-0.8.0/tests/test_evaluate.py +154 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/tests/test_matching.py +11 -10
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/__init__.py +6 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/eval/__init__.py +5 -0
- x4d_devkit-0.8.0/x4d_devkit/eval/api.py +205 -0
- x4d_devkit-0.8.0/x4d_devkit/eval/box.py +124 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/eval/detection.py +8 -114
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/eval/matching.py +28 -33
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit.egg-info/PKG-INFO +1 -1
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit.egg-info/SOURCES.txt +4 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/LICENSE +0 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/README.md +0 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/setup.cfg +0 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/tests/test_coordinate_frames.py +0 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/tests/test_detection_eval.py +0 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/tests/test_fusion.py +0 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/tests/test_loader.py +0 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/tests/test_metrics.py +0 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/tests/test_models.py +0 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/tests/test_nuscenes_converter.py +0 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/tests/test_token.py +0 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/tests/test_transform.py +0 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/tests/test_transform_chain.py +0 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/tests/test_v06_api_additions.py +0 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/tests/test_validation.py +0 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/cli.py +0 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/client/__init__.py +0 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/client/checkpoints.py +0 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/client/client.py +0 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/client/clips.py +0 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/client/projects.py +0 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/client/test_sets.py +0 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/converters/__init__.py +0 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/converters/nuscenes.py +0 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/core/__init__.py +0 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/core/loader.py +0 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/core/models.py +0 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/core/token.py +0 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/core/transform.py +0 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/eval/metrics.py +0 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/fusion.py +0 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/validation/__init__.py +0 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/validation/report.py +0 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/validation/validator.py +0 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit.egg-info/dependency_links.txt +0 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit.egg-info/entry_points.txt +0 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit.egg-info/requires.txt +0 -0
- {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "x4d-devkit"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.8.0"
|
|
8
8
|
description = "X-4D dataset format SDK — load, validate, evaluate, and convert autonomous driving datasets"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Unit tests for the Box detection dataclass."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from x4d_devkit.eval import Box
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestBoxConstruction:
|
|
10
|
+
def test_minimal_gt(self):
|
|
11
|
+
b = Box(
|
|
12
|
+
translation=[1.0, 2.0, 3.0],
|
|
13
|
+
size=[4.0, 2.0, 1.5],
|
|
14
|
+
rotation=[0.0, 0.0, 0.0, 1.0],
|
|
15
|
+
category="car",
|
|
16
|
+
)
|
|
17
|
+
assert b.translation.dtype == np.float64
|
|
18
|
+
assert b.translation.shape == (3,)
|
|
19
|
+
assert b.size.shape == (3,)
|
|
20
|
+
assert b.rotation.shape == (4,)
|
|
21
|
+
assert b.category == "car"
|
|
22
|
+
assert b.score is None
|
|
23
|
+
assert b.velocity is None
|
|
24
|
+
assert b.attributes == []
|
|
25
|
+
|
|
26
|
+
def test_prediction_with_score_and_velocity(self):
|
|
27
|
+
b = Box(
|
|
28
|
+
translation=np.array([10.0, 20.0, 1.0]),
|
|
29
|
+
size=np.array([4.5, 1.8, 1.5]),
|
|
30
|
+
rotation=np.array([0.0, 0.0, 0.0, 1.0]),
|
|
31
|
+
category="car",
|
|
32
|
+
score=0.85,
|
|
33
|
+
velocity=[1.0, 0.5, 0.0],
|
|
34
|
+
attributes=["vehicle.moving"],
|
|
35
|
+
)
|
|
36
|
+
assert b.score == 0.85
|
|
37
|
+
assert b.velocity.dtype == np.float64
|
|
38
|
+
assert b.velocity.tolist() == [1.0, 0.5, 0.0]
|
|
39
|
+
assert b.attributes == ["vehicle.moving"]
|
|
40
|
+
|
|
41
|
+
def test_frozen(self):
|
|
42
|
+
b = Box(
|
|
43
|
+
translation=[0, 0, 0], size=[1, 1, 1],
|
|
44
|
+
rotation=[0, 0, 0, 1], category="car",
|
|
45
|
+
)
|
|
46
|
+
with pytest.raises((AttributeError, Exception)):
|
|
47
|
+
b.category = "truck"
|
|
48
|
+
|
|
49
|
+
def test_rejects_wrong_translation_shape(self):
|
|
50
|
+
with pytest.raises(ValueError, match="translation"):
|
|
51
|
+
Box(translation=[1, 2], size=[1, 1, 1],
|
|
52
|
+
rotation=[0, 0, 0, 1], category="car")
|
|
53
|
+
|
|
54
|
+
def test_rejects_wrong_rotation_shape(self):
|
|
55
|
+
with pytest.raises(ValueError, match="rotation"):
|
|
56
|
+
Box(translation=[1, 2, 3], size=[1, 1, 1],
|
|
57
|
+
rotation=[0, 0, 0], category="car")
|
|
58
|
+
|
|
59
|
+
def test_rejects_wrong_size_shape(self):
|
|
60
|
+
with pytest.raises(ValueError, match="size"):
|
|
61
|
+
Box(translation=[1, 2, 3], size=[1, 1],
|
|
62
|
+
rotation=[0, 0, 0, 1], category="car")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TestFromXyzlwhyawVxvy:
|
|
66
|
+
def test_yaw_zero_gives_identity_quat(self):
|
|
67
|
+
b = Box.from_xyzlwhyaw_vxvy(
|
|
68
|
+
[0, 0, 0, 4, 2, 1.5, 0.0, 0, 0], category="car", score=0.9,
|
|
69
|
+
)
|
|
70
|
+
assert b.rotation[0] == 0.0
|
|
71
|
+
assert b.rotation[1] == 0.0
|
|
72
|
+
assert b.rotation[2] == pytest.approx(0.0)
|
|
73
|
+
assert b.rotation[3] == pytest.approx(1.0)
|
|
74
|
+
|
|
75
|
+
def test_yaw_pi_half_gives_z_quat(self):
|
|
76
|
+
b = Box.from_xyzlwhyaw_vxvy(
|
|
77
|
+
[0, 0, 0, 4, 2, 1.5, np.pi / 2, 0, 0], category="car",
|
|
78
|
+
)
|
|
79
|
+
assert b.rotation[2] == pytest.approx(np.sin(np.pi / 4))
|
|
80
|
+
assert b.rotation[3] == pytest.approx(np.cos(np.pi / 4))
|
|
81
|
+
|
|
82
|
+
def test_yaw_round_trip_via_quaternion_yaw(self):
|
|
83
|
+
from x4d_devkit.eval.metrics import quaternion_yaw
|
|
84
|
+
|
|
85
|
+
for yaw in [-2.5, -1.0, 0.0, 0.7, np.pi - 0.01]:
|
|
86
|
+
b = Box.from_xyzlwhyaw_vxvy(
|
|
87
|
+
[1, 2, 3, 4, 2, 1.5, yaw, 0, 0], category="car",
|
|
88
|
+
)
|
|
89
|
+
recovered = quaternion_yaw(b.rotation)
|
|
90
|
+
assert recovered == pytest.approx(yaw, abs=1e-6)
|
|
91
|
+
|
|
92
|
+
def test_velocity_lifts_to_3d(self):
|
|
93
|
+
b = Box.from_xyzlwhyaw_vxvy(
|
|
94
|
+
[0, 0, 0, 4, 2, 1.5, 0, 1.5, -0.5], category="car",
|
|
95
|
+
)
|
|
96
|
+
assert b.velocity.shape == (3,)
|
|
97
|
+
assert b.velocity.tolist() == [1.5, -0.5, 0.0]
|
|
98
|
+
|
|
99
|
+
def test_translation_and_size_preserved(self):
|
|
100
|
+
b = Box.from_xyzlwhyaw_vxvy(
|
|
101
|
+
[10, 20, 1, 4.5, 1.8, 1.5, 0.0, 0, 0], category="car",
|
|
102
|
+
)
|
|
103
|
+
assert b.translation.tolist() == [10.0, 20.0, 1.0]
|
|
104
|
+
assert b.size.tolist() == [4.5, 1.8, 1.5]
|
|
105
|
+
|
|
106
|
+
def test_rejects_wrong_shape(self):
|
|
107
|
+
with pytest.raises(ValueError, match="bbox must be shape"):
|
|
108
|
+
Box.from_xyzlwhyaw_vxvy([1, 2, 3], category="car")
|
|
109
|
+
|
|
110
|
+
def test_attributes_default_empty(self):
|
|
111
|
+
b = Box.from_xyzlwhyaw_vxvy(
|
|
112
|
+
[0, 0, 0, 4, 2, 1.5, 0, 0, 0], category="car",
|
|
113
|
+
)
|
|
114
|
+
assert b.attributes == []
|
|
115
|
+
|
|
116
|
+
def test_attributes_passthrough(self):
|
|
117
|
+
b = Box.from_xyzlwhyaw_vxvy(
|
|
118
|
+
[0, 0, 0, 4, 2, 1.5, 0, 0, 0], category="car",
|
|
119
|
+
attributes=["vehicle.moving"],
|
|
120
|
+
)
|
|
121
|
+
assert b.attributes == ["vehicle.moving"]
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Unit tests for the in-memory evaluate() pure function."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from x4d_devkit.eval import (
|
|
7
|
+
Box,
|
|
8
|
+
DetectionConfig,
|
|
9
|
+
DetectionResult,
|
|
10
|
+
evaluate,
|
|
11
|
+
nusc_detection_config,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _gt_box(x, y, z, category="car"):
|
|
16
|
+
return Box(
|
|
17
|
+
translation=[x, y, z], size=[4.0, 2.0, 1.5],
|
|
18
|
+
rotation=[0.0, 0.0, 0.0, 1.0], category=category,
|
|
19
|
+
velocity=[0.0, 0.0, 0.0], attributes=[],
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _pred_box(x, y, z, score, category="car"):
|
|
24
|
+
return Box(
|
|
25
|
+
translation=[x, y, z], size=[4.0, 2.0, 1.5],
|
|
26
|
+
rotation=[0.0, 0.0, 0.0, 1.0], category=category,
|
|
27
|
+
score=score, velocity=[0.0, 0.0, 0.0], attributes=[],
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _tiny_config(class_names=("car",)) -> DetectionConfig:
|
|
32
|
+
"""Single-class config with NUSC-style thresholds, no traffic_cone/barrier
|
|
33
|
+
special cases hitting our class."""
|
|
34
|
+
cn = list(class_names)
|
|
35
|
+
return DetectionConfig(
|
|
36
|
+
class_names=cn,
|
|
37
|
+
dist_thresholds=[0.5, 1.0, 2.0, 4.0],
|
|
38
|
+
dist_th_tp=2.0,
|
|
39
|
+
min_recall=0.1,
|
|
40
|
+
min_precision=0.1,
|
|
41
|
+
max_boxes_per_sample=500,
|
|
42
|
+
class_range={c: 50.0 for c in cn},
|
|
43
|
+
category_map={},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TestEvaluatePerfect:
|
|
48
|
+
def test_perfect_single_class_single_sample(self):
|
|
49
|
+
cfg = _tiny_config()
|
|
50
|
+
gt = {"s1": [_gt_box(10, 20, 1)]}
|
|
51
|
+
pred = {"s1": [_pred_box(10, 20, 1, score=0.9)]}
|
|
52
|
+
result = evaluate(gt, pred, cfg)
|
|
53
|
+
assert isinstance(result, DetectionResult)
|
|
54
|
+
assert result.mean_ap == pytest.approx(1.0)
|
|
55
|
+
assert result.mean_dist_aps["car"] == pytest.approx(1.0)
|
|
56
|
+
assert result.label_tp_errors["car"]["trans_err"] < 1e-6
|
|
57
|
+
assert result.label_tp_errors["car"]["scale_err"] < 1e-6
|
|
58
|
+
assert result.nd_score > 0.9
|
|
59
|
+
|
|
60
|
+
def test_perfect_multi_sample(self):
|
|
61
|
+
cfg = _tiny_config()
|
|
62
|
+
gt = {f"s{i}": [_gt_box(10 + i, 0, 0)] for i in range(5)}
|
|
63
|
+
pred = {f"s{i}": [_pred_box(10 + i, 0, 0, score=0.9)] for i in range(5)}
|
|
64
|
+
result = evaluate(gt, pred, cfg)
|
|
65
|
+
assert result.mean_ap == pytest.approx(1.0)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TestEvaluateNoisy:
|
|
69
|
+
def test_translation_noise_increases_trans_err(self):
|
|
70
|
+
cfg = _tiny_config()
|
|
71
|
+
rng = np.random.default_rng(7)
|
|
72
|
+
gt = {f"s{i}": [_gt_box(10.0 * i, 0, 0)] for i in range(20)}
|
|
73
|
+
pred = {
|
|
74
|
+
f"s{i}": [_pred_box(10.0 * i + rng.normal(0, 0.5), 0, 0, score=0.9)]
|
|
75
|
+
for i in range(20)
|
|
76
|
+
}
|
|
77
|
+
result = evaluate(gt, pred, cfg)
|
|
78
|
+
assert result.label_tp_errors["car"]["trans_err"] > 0.1
|
|
79
|
+
assert result.label_tp_errors["car"]["trans_err"] < 2.0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TestEvaluateEmpty:
|
|
83
|
+
def test_empty_predictions_zero_ap(self):
|
|
84
|
+
cfg = _tiny_config()
|
|
85
|
+
gt = {"s1": [_gt_box(10, 20, 1)]}
|
|
86
|
+
pred = {"s1": []}
|
|
87
|
+
result = evaluate(gt, pred, cfg)
|
|
88
|
+
assert result.mean_ap == pytest.approx(0.0)
|
|
89
|
+
|
|
90
|
+
def test_no_gt_for_class_zero_ap(self):
|
|
91
|
+
cfg = _tiny_config()
|
|
92
|
+
gt = {"s1": []}
|
|
93
|
+
pred = {"s1": [_pred_box(10, 20, 1, score=0.9)]}
|
|
94
|
+
result = evaluate(gt, pred, cfg)
|
|
95
|
+
assert result.mean_ap == pytest.approx(0.0)
|
|
96
|
+
|
|
97
|
+
def test_pred_sample_missing_from_gt_treated_as_empty_gt(self):
|
|
98
|
+
cfg = _tiny_config()
|
|
99
|
+
gt = {"s1": [_gt_box(10, 20, 1)]}
|
|
100
|
+
pred = {"s2": [_pred_box(10, 20, 1, score=0.9)]}
|
|
101
|
+
result = evaluate(gt, pred, cfg)
|
|
102
|
+
assert result.mean_ap == pytest.approx(0.0)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class TestEvaluateClassRangeFilter:
|
|
106
|
+
def test_filters_far_boxes(self):
|
|
107
|
+
cfg = _tiny_config()
|
|
108
|
+
cfg = DetectionConfig(
|
|
109
|
+
class_names=["car"],
|
|
110
|
+
dist_thresholds=cfg.dist_thresholds,
|
|
111
|
+
dist_th_tp=cfg.dist_th_tp,
|
|
112
|
+
min_recall=cfg.min_recall,
|
|
113
|
+
min_precision=cfg.min_precision,
|
|
114
|
+
max_boxes_per_sample=cfg.max_boxes_per_sample,
|
|
115
|
+
class_range={"car": 10.0},
|
|
116
|
+
category_map={},
|
|
117
|
+
)
|
|
118
|
+
gt = {"s1": [_gt_box(100, 0, 0), _gt_box(5, 0, 0)]}
|
|
119
|
+
pred = {"s1": [_pred_box(5, 0, 0, score=0.9)]}
|
|
120
|
+
result = evaluate(gt, pred, cfg)
|
|
121
|
+
# The far GT box is filtered out, near GT matches → AP = 1.0.
|
|
122
|
+
assert result.mean_ap == pytest.approx(1.0)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class TestEvaluateMaxBoxesPerSample:
|
|
126
|
+
def test_low_score_predictions_dropped_when_capped(self):
|
|
127
|
+
cfg = DetectionConfig(
|
|
128
|
+
class_names=["car"],
|
|
129
|
+
dist_thresholds=[0.5, 1.0, 2.0, 4.0],
|
|
130
|
+
dist_th_tp=2.0,
|
|
131
|
+
min_recall=0.1, min_precision=0.1,
|
|
132
|
+
max_boxes_per_sample=1,
|
|
133
|
+
class_range={"car": 50.0},
|
|
134
|
+
category_map={},
|
|
135
|
+
)
|
|
136
|
+
gt = {"s1": [_gt_box(10, 0, 0)]}
|
|
137
|
+
pred = {"s1": [
|
|
138
|
+
_pred_box(40, 0, 0, score=0.95), # high-score FP, kept by cap
|
|
139
|
+
_pred_box(10, 0, 0, score=0.10), # low-score TP, dropped by cap
|
|
140
|
+
]}
|
|
141
|
+
result = evaluate(gt, pred, cfg)
|
|
142
|
+
# The TP was dropped, only FP remains → mAP near 0.
|
|
143
|
+
assert result.mean_ap < 0.1
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class TestEvaluateMatchesDetectionEval:
|
|
147
|
+
"""evaluate() is what DetectionEval.evaluate() delegates to — sanity-check
|
|
148
|
+
they produce identical numbers when fed equivalent inputs."""
|
|
149
|
+
|
|
150
|
+
def test_no_gt_no_pred_zero(self):
|
|
151
|
+
cfg = _tiny_config()
|
|
152
|
+
result = evaluate({}, {}, cfg)
|
|
153
|
+
assert result.mean_ap == 0.0
|
|
154
|
+
assert result.label_aps["car"]["0.5"] == 0.0
|
|
@@ -4,21 +4,22 @@
|
|
|
4
4
|
import numpy as np
|
|
5
5
|
import pytest
|
|
6
6
|
|
|
7
|
+
from x4d_devkit.eval import Box
|
|
7
8
|
from x4d_devkit.eval.matching import MetricData, accumulate
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
def _make_box(translation, size, rotation, category, score=None,
|
|
11
12
|
velocity=None, attributes=None):
|
|
12
|
-
"""Helper to
|
|
13
|
-
return
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
13
|
+
"""Helper to construct a Box matching what accumulate() expects."""
|
|
14
|
+
return Box(
|
|
15
|
+
translation=np.asarray(translation, dtype=float),
|
|
16
|
+
size=np.asarray(size, dtype=float),
|
|
17
|
+
rotation=np.asarray(rotation, dtype=float),
|
|
18
|
+
category=category,
|
|
19
|
+
score=score,
|
|
20
|
+
velocity=np.asarray(velocity, dtype=float) if velocity is not None else None,
|
|
21
|
+
attributes=attributes or [],
|
|
22
|
+
)
|
|
22
23
|
|
|
23
24
|
|
|
24
25
|
class TestMetricData:
|
|
@@ -25,11 +25,14 @@ from x4d_devkit.core import (
|
|
|
25
25
|
generate_token,
|
|
26
26
|
)
|
|
27
27
|
from x4d_devkit.eval import (
|
|
28
|
+
Box,
|
|
28
29
|
DetectionConfig,
|
|
29
30
|
DetectionEval,
|
|
30
31
|
DetectionResult,
|
|
32
|
+
boxes_from_clip,
|
|
31
33
|
calc_ap,
|
|
32
34
|
calc_tp,
|
|
35
|
+
evaluate,
|
|
33
36
|
nusc_detection_config,
|
|
34
37
|
)
|
|
35
38
|
from x4d_devkit.validation import validate_clip, ValidationReport
|
|
@@ -38,6 +41,7 @@ from x4d_devkit.fusion import LIDAR_FUSED_DTYPE, FusionSource, fuse_pointclouds
|
|
|
38
41
|
__all__ = [
|
|
39
42
|
"__version__",
|
|
40
43
|
"Annotation",
|
|
44
|
+
"Box",
|
|
41
45
|
"CalibratedSensor",
|
|
42
46
|
"LIDAR_FUSED_DTYPE",
|
|
43
47
|
"FusionSource",
|
|
@@ -58,8 +62,10 @@ __all__ = [
|
|
|
58
62
|
"TransformChain",
|
|
59
63
|
"ValidationReport",
|
|
60
64
|
"X4DClient",
|
|
65
|
+
"boxes_from_clip",
|
|
61
66
|
"calc_ap",
|
|
62
67
|
"calc_tp",
|
|
68
|
+
"evaluate",
|
|
63
69
|
"generate_token",
|
|
64
70
|
"nusc_detection_config",
|
|
65
71
|
"validate_clip",
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"""Evaluation layer for X4D dataset."""
|
|
2
2
|
|
|
3
|
+
from x4d_devkit.eval.api import boxes_from_clip, evaluate
|
|
4
|
+
from x4d_devkit.eval.box import Box
|
|
3
5
|
from x4d_devkit.eval.detection import (
|
|
4
6
|
DetectionConfig,
|
|
5
7
|
DetectionEval,
|
|
@@ -11,11 +13,14 @@ from x4d_devkit.eval.detection import (
|
|
|
11
13
|
)
|
|
12
14
|
|
|
13
15
|
__all__ = [
|
|
16
|
+
"Box",
|
|
14
17
|
"DetectionConfig",
|
|
15
18
|
"DetectionEval",
|
|
16
19
|
"DetectionResult",
|
|
20
|
+
"boxes_from_clip",
|
|
17
21
|
"calc_ap",
|
|
18
22
|
"calc_tp",
|
|
19
23
|
"detzero_detection_config",
|
|
24
|
+
"evaluate",
|
|
20
25
|
"nusc_detection_config",
|
|
21
26
|
]
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Pure-function detection evaluation API.
|
|
2
|
+
|
|
3
|
+
`evaluate(gt, pred, config)` consumes in-memory ``dict[str, list[Box]]``
|
|
4
|
+
without requiring on-disk clip directories — designed for the metric
|
|
5
|
+
callback hot path of training pipelines. Use ``boxes_from_clip(loader,
|
|
6
|
+
config)`` to build the dicts from a ``ClipLoader`` (replicating the
|
|
7
|
+
behaviour previously hidden inside ``DetectionEval._extract_boxes``).
|
|
8
|
+
|
|
9
|
+
The two-step split is intentional: extraction (which applies the
|
|
10
|
+
config's ``category_map`` and ``class_range``) is decoupled from
|
|
11
|
+
evaluation (per-class accumulate → AP → TP errors → NDS), so training
|
|
12
|
+
code that already has predictions in memory never has to round-trip
|
|
13
|
+
through disk.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import TYPE_CHECKING
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from x4d_devkit.eval.box import Box
|
|
23
|
+
from x4d_devkit.eval.matching import MetricData, accumulate
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from x4d_devkit.core.loader import ClipLoader
|
|
27
|
+
from x4d_devkit.eval.detection import DetectionConfig, DetectionResult
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
TP_METRICS = ["trans_err", "scale_err", "orient_err", "vel_err", "attr_err"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _filter_for_eval(
|
|
34
|
+
boxes: dict[str, list[Box]],
|
|
35
|
+
class_names: list[str],
|
|
36
|
+
class_range: dict[str, float],
|
|
37
|
+
max_boxes: int | None = None,
|
|
38
|
+
) -> dict[str, list[Box]]:
|
|
39
|
+
"""Drop boxes whose category isn't in ``class_names`` or whose ego-frame
|
|
40
|
+
XY distance exceeds the per-class range. Optionally cap predictions per
|
|
41
|
+
sample (top-N by score)."""
|
|
42
|
+
class_names_set = set(class_names)
|
|
43
|
+
out: dict[str, list[Box]] = {}
|
|
44
|
+
for sample_token, sample_boxes in boxes.items():
|
|
45
|
+
kept = []
|
|
46
|
+
for b in sample_boxes:
|
|
47
|
+
if b.category not in class_names_set:
|
|
48
|
+
continue
|
|
49
|
+
r = class_range.get(b.category)
|
|
50
|
+
if r is not None:
|
|
51
|
+
if float(np.linalg.norm(b.translation[:2])) > r:
|
|
52
|
+
continue
|
|
53
|
+
kept.append(b)
|
|
54
|
+
if max_boxes is not None and len(kept) > max_boxes:
|
|
55
|
+
kept.sort(key=lambda b: (b.score if b.score is not None else 0.0), reverse=True)
|
|
56
|
+
kept = kept[:max_boxes]
|
|
57
|
+
out[sample_token] = kept
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def boxes_from_clip(
|
|
62
|
+
clip: ClipLoader,
|
|
63
|
+
config: DetectionConfig,
|
|
64
|
+
*,
|
|
65
|
+
apply_score_cap: bool = False,
|
|
66
|
+
) -> dict[str, list[Box]]:
|
|
67
|
+
"""Extract per-sample boxes from a ClipLoader, applying the config's
|
|
68
|
+
category mapping and class-range distance filter.
|
|
69
|
+
|
|
70
|
+
v0.5: annotations are already in per-sample ego frame, so the
|
|
71
|
+
distance filter is just ``||translation[:2]||`` — no ego_pose
|
|
72
|
+
subtraction.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
clip: Source clip.
|
|
76
|
+
config: Provides ``category_map``, ``class_names``, ``class_range``,
|
|
77
|
+
and (when ``apply_score_cap=True``) ``max_boxes_per_sample``.
|
|
78
|
+
apply_score_cap: Set to True for *prediction* clips so the per-sample
|
|
79
|
+
top-K cap from ``config.max_boxes_per_sample`` is applied. GT
|
|
80
|
+
extraction leaves it False.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
``{sample_token: [Box, ...]}`` keyed exactly as ``evaluate()`` expects.
|
|
84
|
+
"""
|
|
85
|
+
from x4d_devkit.eval.detection import map_category
|
|
86
|
+
|
|
87
|
+
out: dict[str, list[Box]] = {}
|
|
88
|
+
cap = config.max_boxes_per_sample if apply_score_cap else None
|
|
89
|
+
for sample in clip.samples:
|
|
90
|
+
anns = clip.annotations_for_sample(sample.token)
|
|
91
|
+
kept: list[Box] = []
|
|
92
|
+
for ann in anns:
|
|
93
|
+
det_name = map_category(ann.category, config.category_map, config.class_names)
|
|
94
|
+
if det_name is None or det_name not in config.class_range:
|
|
95
|
+
continue
|
|
96
|
+
dist = float(np.linalg.norm(ann.translation[:2]))
|
|
97
|
+
if dist > config.class_range[det_name]:
|
|
98
|
+
continue
|
|
99
|
+
kept.append(Box(
|
|
100
|
+
translation=ann.translation,
|
|
101
|
+
size=ann.size,
|
|
102
|
+
rotation=ann.rotation,
|
|
103
|
+
category=det_name,
|
|
104
|
+
score=ann.score,
|
|
105
|
+
velocity=ann.velocity,
|
|
106
|
+
attributes=list(ann.attributes),
|
|
107
|
+
))
|
|
108
|
+
if cap is not None and len(kept) > cap:
|
|
109
|
+
kept.sort(key=lambda b: (b.score if b.score is not None else 0.0), reverse=True)
|
|
110
|
+
kept = kept[:cap]
|
|
111
|
+
out[sample.token] = kept
|
|
112
|
+
return out
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def evaluate(
|
|
116
|
+
gt: dict[str, list[Box]],
|
|
117
|
+
pred: dict[str, list[Box]],
|
|
118
|
+
config: DetectionConfig,
|
|
119
|
+
) -> DetectionResult:
|
|
120
|
+
"""Run detection evaluation on in-memory boxes.
|
|
121
|
+
|
|
122
|
+
The training-pipeline entry point: feed dicts of ``Box`` instances
|
|
123
|
+
keyed by sample_token, get back the same ``DetectionResult`` that
|
|
124
|
+
``DetectionEval.evaluate()`` produces. No disk I/O, no ClipLoader
|
|
125
|
+
requirement.
|
|
126
|
+
|
|
127
|
+
The function applies ``config.class_range`` distance filtering and the
|
|
128
|
+
``config.max_boxes_per_sample`` cap on predictions, but does **not**
|
|
129
|
+
apply ``config.category_map`` — predictions are expected to already
|
|
130
|
+
carry final class names. Use ``boxes_from_clip()`` if you need the
|
|
131
|
+
mapping applied.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
gt: ``{sample_token: [Box, ...]}`` for ground truth. Box.score
|
|
135
|
+
should be ``None``.
|
|
136
|
+
pred: ``{sample_token: [Box, ...]}`` for predictions. Box.score
|
|
137
|
+
must be a float.
|
|
138
|
+
config: Evaluation configuration.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
DetectionResult with mAP, NDS, per-class APs, and TP errors.
|
|
142
|
+
"""
|
|
143
|
+
from x4d_devkit.eval.detection import DetectionResult, calc_ap, calc_tp
|
|
144
|
+
|
|
145
|
+
cfg = config
|
|
146
|
+
gt_f = _filter_for_eval(gt, cfg.class_names, cfg.class_range)
|
|
147
|
+
pred_f = _filter_for_eval(
|
|
148
|
+
pred, cfg.class_names, cfg.class_range, max_boxes=cfg.max_boxes_per_sample
|
|
149
|
+
)
|
|
150
|
+
for st in gt_f:
|
|
151
|
+
pred_f.setdefault(st, [])
|
|
152
|
+
|
|
153
|
+
metric_data: dict[str, dict[str, MetricData]] = {}
|
|
154
|
+
for class_name in cfg.class_names:
|
|
155
|
+
metric_data[class_name] = {}
|
|
156
|
+
for dist_th in cfg.dist_thresholds:
|
|
157
|
+
metric_data[class_name][str(dist_th)] = accumulate(
|
|
158
|
+
gt_f, pred_f, class_name, dist_th
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
label_aps: dict[str, dict[str, float]] = {}
|
|
162
|
+
for class_name in cfg.class_names:
|
|
163
|
+
label_aps[class_name] = {}
|
|
164
|
+
for dist_th in cfg.dist_thresholds:
|
|
165
|
+
md = metric_data[class_name][str(dist_th)]
|
|
166
|
+
label_aps[class_name][str(dist_th)] = calc_ap(md, cfg.min_recall, cfg.min_precision)
|
|
167
|
+
|
|
168
|
+
mean_dist_aps = {
|
|
169
|
+
cn: float(np.mean(list(label_aps[cn].values())))
|
|
170
|
+
for cn in cfg.class_names
|
|
171
|
+
}
|
|
172
|
+
all_ap_values = [ap for cn_aps in label_aps.values() for ap in cn_aps.values()]
|
|
173
|
+
mean_ap = float(np.mean(all_ap_values)) if all_ap_values else 0.0
|
|
174
|
+
|
|
175
|
+
label_tp_errors: dict[str, dict[str, float]] = {}
|
|
176
|
+
for class_name in cfg.class_names:
|
|
177
|
+
md = metric_data[class_name][str(cfg.dist_th_tp)]
|
|
178
|
+
label_tp_errors[class_name] = {}
|
|
179
|
+
for metric_name in TP_METRICS:
|
|
180
|
+
if class_name == "traffic_cone" and metric_name in ("attr_err", "vel_err", "orient_err"):
|
|
181
|
+
label_tp_errors[class_name][metric_name] = float("nan")
|
|
182
|
+
elif class_name == "barrier" and metric_name in ("attr_err", "vel_err"):
|
|
183
|
+
label_tp_errors[class_name][metric_name] = float("nan")
|
|
184
|
+
else:
|
|
185
|
+
label_tp_errors[class_name][metric_name] = calc_tp(md, cfg.min_recall, metric_name)
|
|
186
|
+
|
|
187
|
+
tp_errors = {}
|
|
188
|
+
for metric_name in TP_METRICS:
|
|
189
|
+
values = [label_tp_errors[cn][metric_name] for cn in cfg.class_names]
|
|
190
|
+
tp_errors[metric_name] = float(np.nanmean(values))
|
|
191
|
+
|
|
192
|
+
tp_scores = {m: max(0.0, 1.0 - tp_errors[m]) for m in TP_METRICS}
|
|
193
|
+
nd_score = (cfg.mean_ap_weight * mean_ap + sum(tp_scores.values())) / (
|
|
194
|
+
cfg.mean_ap_weight + len(tp_scores)
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
return DetectionResult(
|
|
198
|
+
mean_ap=mean_ap,
|
|
199
|
+
nd_score=nd_score,
|
|
200
|
+
label_aps=label_aps,
|
|
201
|
+
mean_dist_aps=mean_dist_aps,
|
|
202
|
+
tp_errors=tp_errors,
|
|
203
|
+
label_tp_errors=label_tp_errors,
|
|
204
|
+
tp_scores=tp_scores,
|
|
205
|
+
)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Typed 3D bounding box for detection evaluation.
|
|
2
|
+
|
|
3
|
+
`Box` is the data shape that ``evaluate()`` and ``accumulate()`` consume.
|
|
4
|
+
It mirrors the fields previously expected as a dict (``translation``,
|
|
5
|
+
``size``, ``rotation``, ``category``, ``score``, ``velocity``,
|
|
6
|
+
``attributes``) but with type checking, frozen semantics, and
|
|
7
|
+
constructor helpers for common upstream LiDAR detection layouts.
|
|
8
|
+
|
|
9
|
+
GT vs. prediction is distinguished by ``score`` — None for ground truth,
|
|
10
|
+
float for predictions. ``sample_token`` is *not* a field on Box; it's
|
|
11
|
+
the key of the outer ``dict[str, list[Box]]`` passed to ``evaluate``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Sequence
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Box:
|
|
24
|
+
"""A 3D bounding box for detection evaluation.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
translation: (3,) float64 — box center [x, y, z] in meters,
|
|
28
|
+
in **per-sample ego frame** (matching annotations v0.5).
|
|
29
|
+
size: (3,) float64 — [length, width, height] in meters.
|
|
30
|
+
rotation: (4,) float64 — box orientation as quaternion
|
|
31
|
+
[qx, qy, qz, qw]. Last-w convention (nuScenes-compatible).
|
|
32
|
+
category: Final detection class name (e.g. ``"car"``,
|
|
33
|
+
``"Vehicle"``). Class mapping from raw hierarchical labels
|
|
34
|
+
(``"vehicle.car"`` → ``"car"``) is the caller's
|
|
35
|
+
responsibility — ``Box.category`` is consumed verbatim.
|
|
36
|
+
score: Detection confidence in [0, 1] for predictions, or
|
|
37
|
+
``None`` for ground truth.
|
|
38
|
+
velocity: (3,) float64 [vx, vy, vz] in m/s in the same ego
|
|
39
|
+
frame, or ``None`` if velocity is unknown. ``vel_err``
|
|
40
|
+
(AVE) uses only the [:2] xy component.
|
|
41
|
+
attributes: List of attribute strings (e.g.
|
|
42
|
+
``["vehicle.moving"]``). Empty list = no attributes.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
translation: np.ndarray
|
|
46
|
+
size: np.ndarray
|
|
47
|
+
rotation: np.ndarray
|
|
48
|
+
category: str
|
|
49
|
+
score: float | None = None
|
|
50
|
+
velocity: np.ndarray | None = None
|
|
51
|
+
attributes: list[str] = field(default_factory=list)
|
|
52
|
+
|
|
53
|
+
def __post_init__(self) -> None:
|
|
54
|
+
# Coerce array fields to float64 numpy arrays.
|
|
55
|
+
object.__setattr__(self, "translation", np.asarray(self.translation, dtype=np.float64))
|
|
56
|
+
object.__setattr__(self, "size", np.asarray(self.size, dtype=np.float64))
|
|
57
|
+
object.__setattr__(self, "rotation", np.asarray(self.rotation, dtype=np.float64))
|
|
58
|
+
if self.velocity is not None:
|
|
59
|
+
object.__setattr__(self, "velocity", np.asarray(self.velocity, dtype=np.float64))
|
|
60
|
+
|
|
61
|
+
if self.translation.shape != (3,):
|
|
62
|
+
raise ValueError(
|
|
63
|
+
f"Box.translation must be shape (3,), got {self.translation.shape}"
|
|
64
|
+
)
|
|
65
|
+
if self.size.shape != (3,):
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"Box.size must be shape (3,), got {self.size.shape}"
|
|
68
|
+
)
|
|
69
|
+
if self.rotation.shape != (4,):
|
|
70
|
+
raise ValueError(
|
|
71
|
+
f"Box.rotation must be shape (4,) [qx, qy, qz, qw], got {self.rotation.shape}"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def from_xyzlwhyaw_vxvy(
|
|
76
|
+
cls,
|
|
77
|
+
bbox: Sequence[float] | np.ndarray,
|
|
78
|
+
category: str,
|
|
79
|
+
score: float | None = None,
|
|
80
|
+
attributes: list[str] | None = None,
|
|
81
|
+
) -> Box:
|
|
82
|
+
"""Construct a Box from a 9-D LiDAR-frame array.
|
|
83
|
+
|
|
84
|
+
Layout: ``[x, y, z, l, w, h, yaw, vx, vy]``. This is the de-facto
|
|
85
|
+
encoding used by mmdetection3d's ``LiDARInstance3DBoxes`` (with
|
|
86
|
+
velocity head), OpenPCDet, and DetZero. The yaw is converted to a
|
|
87
|
+
quaternion ``[0, 0, sin(yaw/2), cos(yaw/2)]``; ``(vx, vy)`` is
|
|
88
|
+
wrapped into a ``(3,)`` velocity array with ``vz = 0``.
|
|
89
|
+
|
|
90
|
+
Use this in training-pipeline metric callbacks to convert per-batch
|
|
91
|
+
detector outputs directly to evaluation-ready boxes — instead of
|
|
92
|
+
passing ``rotation=[0, 0, 0, 1]`` placeholders that silently zero
|
|
93
|
+
out the orientation error (AOE) component.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
bbox: 9-element array ``[x, y, z, l, w, h, yaw, vx, vy]``.
|
|
97
|
+
category: Final detection class name.
|
|
98
|
+
score: Detection confidence (predictions) or None (GT).
|
|
99
|
+
attributes: Optional attribute strings.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
A frozen Box.
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
ValueError: If ``bbox`` is not 9-element.
|
|
106
|
+
"""
|
|
107
|
+
b = np.asarray(bbox, dtype=np.float64)
|
|
108
|
+
if b.shape != (9,):
|
|
109
|
+
raise ValueError(
|
|
110
|
+
f"bbox must be shape (9,) [x, y, z, l, w, h, yaw, vx, vy], got {b.shape}"
|
|
111
|
+
)
|
|
112
|
+
x, y, z, l, w, h, yaw, vx, vy = b
|
|
113
|
+
half = 0.5 * float(yaw)
|
|
114
|
+
rotation = np.array([0.0, 0.0, np.sin(half), np.cos(half)], dtype=np.float64)
|
|
115
|
+
velocity = np.array([float(vx), float(vy), 0.0], dtype=np.float64)
|
|
116
|
+
return cls(
|
|
117
|
+
translation=np.array([x, y, z], dtype=np.float64),
|
|
118
|
+
size=np.array([l, w, h], dtype=np.float64),
|
|
119
|
+
rotation=rotation,
|
|
120
|
+
category=category,
|
|
121
|
+
score=score,
|
|
122
|
+
velocity=velocity,
|
|
123
|
+
attributes=list(attributes) if attributes is not None else [],
|
|
124
|
+
)
|
|
@@ -17,8 +17,7 @@ from typing import TYPE_CHECKING
|
|
|
17
17
|
|
|
18
18
|
import numpy as np
|
|
19
19
|
|
|
20
|
-
from x4d_devkit.eval.matching import MetricData
|
|
21
|
-
from x4d_devkit.eval.metrics import center_distance
|
|
20
|
+
from x4d_devkit.eval.matching import MetricData
|
|
22
21
|
|
|
23
22
|
if TYPE_CHECKING:
|
|
24
23
|
from x4d_devkit.core.loader import ClipLoader
|
|
@@ -223,50 +222,6 @@ _calc_ap = calc_ap
|
|
|
223
222
|
_calc_tp = calc_tp
|
|
224
223
|
|
|
225
224
|
|
|
226
|
-
def _extract_boxes(
|
|
227
|
-
clip: ClipLoader,
|
|
228
|
-
config: DetectionConfig,
|
|
229
|
-
max_boxes: int | None = None,
|
|
230
|
-
) -> dict[str, list[dict]]:
|
|
231
|
-
"""Extract annotation boxes grouped by sample_token, with distance filtering.
|
|
232
|
-
|
|
233
|
-
v0.5: annotations are in per-sample ego frame, so distance from ego is
|
|
234
|
-
simply ``||ann.translation[:2]||`` — no subtraction of ego_pose required.
|
|
235
|
-
"""
|
|
236
|
-
category_map = config.category_map or {}
|
|
237
|
-
class_names = config.class_names
|
|
238
|
-
class_range = config.class_range
|
|
239
|
-
|
|
240
|
-
result: dict[str, list[dict]] = {}
|
|
241
|
-
for sample in clip.samples:
|
|
242
|
-
anns = clip.annotations_for_sample(sample.token) # default = ego in v0.5
|
|
243
|
-
boxes = []
|
|
244
|
-
for ann in anns:
|
|
245
|
-
det_name = map_category(ann.category, category_map, class_names)
|
|
246
|
-
if det_name is None or det_name not in class_range:
|
|
247
|
-
continue
|
|
248
|
-
dist = float(np.linalg.norm(ann.translation[:2]))
|
|
249
|
-
if dist > class_range[det_name]:
|
|
250
|
-
continue
|
|
251
|
-
boxes.append({
|
|
252
|
-
"translation": ann.translation,
|
|
253
|
-
"size": ann.size,
|
|
254
|
-
"rotation": ann.rotation,
|
|
255
|
-
"category": det_name,
|
|
256
|
-
"score": ann.score,
|
|
257
|
-
"velocity": ann.velocity,
|
|
258
|
-
"attributes": ann.attributes,
|
|
259
|
-
})
|
|
260
|
-
|
|
261
|
-
if max_boxes is not None and len(boxes) > max_boxes:
|
|
262
|
-
boxes.sort(key=lambda b: b["score"] or 0, reverse=True)
|
|
263
|
-
boxes = boxes[:max_boxes]
|
|
264
|
-
|
|
265
|
-
result[sample.token] = boxes
|
|
266
|
-
|
|
267
|
-
return result
|
|
268
|
-
|
|
269
|
-
|
|
270
225
|
class DetectionEval:
|
|
271
226
|
"""3D object detection evaluator.
|
|
272
227
|
|
|
@@ -306,82 +261,21 @@ class DetectionEval:
|
|
|
306
261
|
Returns:
|
|
307
262
|
DetectionResult with mAP, NDS, per-class APs, and TP errors.
|
|
308
263
|
"""
|
|
309
|
-
|
|
264
|
+
from x4d_devkit.eval.api import boxes_from_clip, evaluate as _evaluate
|
|
310
265
|
|
|
266
|
+
cfg = self._config
|
|
311
267
|
gt_by_id = {c.meta.clip_id: c for c in self._gt_clips}
|
|
312
268
|
pred_by_id = {c.meta.clip_id: c for c in self._pred_clips}
|
|
313
269
|
|
|
314
|
-
all_gt: dict[str, list
|
|
315
|
-
all_pred: dict[str, list
|
|
270
|
+
all_gt: dict[str, list] = {}
|
|
271
|
+
all_pred: dict[str, list] = {}
|
|
316
272
|
|
|
317
273
|
for clip_id, gt_clip in gt_by_id.items():
|
|
318
|
-
|
|
319
|
-
for st, boxes in gt_boxes.items():
|
|
274
|
+
for st, boxes in boxes_from_clip(gt_clip, cfg).items():
|
|
320
275
|
all_gt[st] = boxes
|
|
321
|
-
|
|
322
276
|
pred_clip = pred_by_id.get(clip_id)
|
|
323
277
|
if pred_clip is not None:
|
|
324
|
-
|
|
325
|
-
for st, boxes in pred_boxes.items():
|
|
278
|
+
for st, boxes in boxes_from_clip(pred_clip, cfg, apply_score_cap=True).items():
|
|
326
279
|
all_pred[st] = boxes
|
|
327
280
|
|
|
328
|
-
|
|
329
|
-
if st not in all_pred:
|
|
330
|
-
all_pred[st] = []
|
|
331
|
-
|
|
332
|
-
# Accumulate per class per distance threshold
|
|
333
|
-
metric_data: dict[str, dict[str, MetricData]] = {}
|
|
334
|
-
for class_name in cfg.class_names:
|
|
335
|
-
metric_data[class_name] = {}
|
|
336
|
-
for dist_th in cfg.dist_thresholds:
|
|
337
|
-
metric_data[class_name][str(dist_th)] = accumulate(
|
|
338
|
-
all_gt, all_pred, class_name, dist_th
|
|
339
|
-
)
|
|
340
|
-
|
|
341
|
-
# AP per class per threshold
|
|
342
|
-
label_aps: dict[str, dict[str, float]] = {}
|
|
343
|
-
for class_name in cfg.class_names:
|
|
344
|
-
label_aps[class_name] = {}
|
|
345
|
-
for dist_th in cfg.dist_thresholds:
|
|
346
|
-
md = metric_data[class_name][str(dist_th)]
|
|
347
|
-
label_aps[class_name][str(dist_th)] = calc_ap(md, cfg.min_recall, cfg.min_precision)
|
|
348
|
-
|
|
349
|
-
mean_dist_aps = {
|
|
350
|
-
cn: float(np.mean(list(label_aps[cn].values())))
|
|
351
|
-
for cn in cfg.class_names
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
all_ap_values = [ap for cn_aps in label_aps.values() for ap in cn_aps.values()]
|
|
355
|
-
mean_ap = float(np.mean(all_ap_values)) if all_ap_values else 0.0
|
|
356
|
-
|
|
357
|
-
# TP errors per class at dist_th_tp
|
|
358
|
-
label_tp_errors: dict[str, dict[str, float]] = {}
|
|
359
|
-
for class_name in cfg.class_names:
|
|
360
|
-
md = metric_data[class_name][str(cfg.dist_th_tp)]
|
|
361
|
-
label_tp_errors[class_name] = {}
|
|
362
|
-
for metric_name in TP_METRICS:
|
|
363
|
-
if class_name == "traffic_cone" and metric_name in ("attr_err", "vel_err", "orient_err"):
|
|
364
|
-
label_tp_errors[class_name][metric_name] = float("nan")
|
|
365
|
-
elif class_name == "barrier" and metric_name in ("attr_err", "vel_err"):
|
|
366
|
-
label_tp_errors[class_name][metric_name] = float("nan")
|
|
367
|
-
else:
|
|
368
|
-
label_tp_errors[class_name][metric_name] = calc_tp(md, cfg.min_recall, metric_name)
|
|
369
|
-
|
|
370
|
-
tp_errors = {}
|
|
371
|
-
for metric_name in TP_METRICS:
|
|
372
|
-
values = [label_tp_errors[cn][metric_name] for cn in cfg.class_names]
|
|
373
|
-
tp_errors[metric_name] = float(np.nanmean(values))
|
|
374
|
-
|
|
375
|
-
tp_scores = {m: max(0.0, 1.0 - tp_errors[m]) for m in TP_METRICS}
|
|
376
|
-
|
|
377
|
-
nd_score = (cfg.mean_ap_weight * mean_ap + sum(tp_scores.values())) / (cfg.mean_ap_weight + len(tp_scores))
|
|
378
|
-
|
|
379
|
-
return DetectionResult(
|
|
380
|
-
mean_ap=mean_ap,
|
|
381
|
-
nd_score=nd_score,
|
|
382
|
-
label_aps=label_aps,
|
|
383
|
-
mean_dist_aps=mean_dist_aps,
|
|
384
|
-
tp_errors=tp_errors,
|
|
385
|
-
label_tp_errors=label_tp_errors,
|
|
386
|
-
tp_scores=tp_scores,
|
|
387
|
-
)
|
|
281
|
+
return _evaluate(all_gt, all_pred, cfg)
|
|
@@ -7,12 +7,13 @@ from dataclasses import dataclass
|
|
|
7
7
|
|
|
8
8
|
import numpy as np
|
|
9
9
|
|
|
10
|
+
from x4d_devkit.eval.box import Box
|
|
10
11
|
from x4d_devkit.eval.metrics import (
|
|
12
|
+
attr_acc,
|
|
11
13
|
center_distance,
|
|
14
|
+
cummean,
|
|
12
15
|
scale_iou,
|
|
13
16
|
yaw_diff,
|
|
14
|
-
attr_acc,
|
|
15
|
-
cummean,
|
|
16
17
|
)
|
|
17
18
|
|
|
18
19
|
NELEM = 101 # Number of recall interpolation points
|
|
@@ -55,52 +56,49 @@ class MetricData:
|
|
|
55
56
|
|
|
56
57
|
|
|
57
58
|
def accumulate(
|
|
58
|
-
gt_boxes: dict[str, list[
|
|
59
|
-
pred_boxes: dict[str, list[
|
|
59
|
+
gt_boxes: dict[str, list[Box]],
|
|
60
|
+
pred_boxes: dict[str, list[Box]],
|
|
60
61
|
class_name: str,
|
|
61
62
|
dist_th: float,
|
|
62
63
|
) -> MetricData:
|
|
63
64
|
"""Match predictions to ground truth and compute interpolated curves.
|
|
64
65
|
|
|
65
66
|
Args:
|
|
66
|
-
gt_boxes: {sample_token: [
|
|
67
|
-
pred_boxes: {sample_token: [
|
|
68
|
-
|
|
67
|
+
gt_boxes: ``{sample_token: [Box, ...]}`` — all GT boxes across samples.
|
|
68
|
+
pred_boxes: ``{sample_token: [Box, ...]}`` — all pred boxes across samples.
|
|
69
|
+
Each prediction Box must have a non-None ``score``.
|
|
70
|
+
class_name: Class to evaluate (e.g. ``"car"``).
|
|
69
71
|
dist_th: Center distance threshold for matching.
|
|
70
72
|
|
|
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
73
|
Returns:
|
|
75
74
|
MetricData with interpolated curves at 101 recall points.
|
|
76
75
|
"""
|
|
77
|
-
|
|
78
|
-
gt_by_sample: dict[str, list[dict]] = {}
|
|
76
|
+
gt_by_sample: dict[str, list[Box]] = {}
|
|
79
77
|
npos = 0
|
|
80
78
|
for sample_token, boxes in gt_boxes.items():
|
|
81
|
-
class_boxes = [b for b in boxes if b
|
|
79
|
+
class_boxes = [b for b in boxes if b.category == class_name]
|
|
82
80
|
gt_by_sample[sample_token] = class_boxes
|
|
83
81
|
npos += len(class_boxes)
|
|
84
82
|
|
|
85
|
-
pred_by_sample: dict[str, list[
|
|
86
|
-
all_preds = []
|
|
83
|
+
pred_by_sample: dict[str, list[Box]] = {}
|
|
84
|
+
all_preds: list[tuple[float, str, int]] = []
|
|
87
85
|
for sample_token, boxes in pred_boxes.items():
|
|
88
|
-
class_boxes = [b for b in boxes if b
|
|
89
|
-
class_boxes.sort(key=lambda b: b
|
|
86
|
+
class_boxes = [b for b in boxes if b.category == class_name]
|
|
87
|
+
class_boxes.sort(key=lambda b: b.score, reverse=True)
|
|
90
88
|
pred_by_sample[sample_token] = class_boxes
|
|
91
89
|
for i, b in enumerate(class_boxes):
|
|
92
|
-
all_preds.append((b
|
|
90
|
+
all_preds.append((float(b.score), sample_token, i))
|
|
93
91
|
|
|
94
92
|
if npos == 0:
|
|
95
93
|
return MetricData.no_predictions()
|
|
96
94
|
|
|
97
|
-
# Sort all predictions globally by score descending
|
|
98
95
|
all_preds.sort(key=lambda x: x[0], reverse=True)
|
|
99
96
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
97
|
+
tp_list: list[int] = []
|
|
98
|
+
conf_list: list[float] = []
|
|
99
|
+
match_data: dict[str, list[float]] = {
|
|
100
|
+
k: [] for k in ("trans_err", "scale_err", "orient_err", "vel_err", "attr_err")
|
|
101
|
+
}
|
|
104
102
|
matched_gt: dict[str, set[int]] = {st: set() for st in gt_by_sample}
|
|
105
103
|
|
|
106
104
|
for score, sample_token, pred_idx in all_preds:
|
|
@@ -112,7 +110,7 @@ def accumulate(
|
|
|
112
110
|
for gt_idx, gt in enumerate(gt_list):
|
|
113
111
|
if gt_idx in matched_gt.get(sample_token, set()):
|
|
114
112
|
continue
|
|
115
|
-
d = center_distance(pred
|
|
113
|
+
d = center_distance(pred.translation, gt.translation)
|
|
116
114
|
if d < min_dist:
|
|
117
115
|
min_dist = d
|
|
118
116
|
min_gt_idx = gt_idx
|
|
@@ -124,23 +122,22 @@ def accumulate(
|
|
|
124
122
|
if is_tp:
|
|
125
123
|
matched_gt[sample_token].add(min_gt_idx)
|
|
126
124
|
gt = gt_list[min_gt_idx]
|
|
127
|
-
match_data["trans_err"].append(center_distance(pred
|
|
128
|
-
match_data["scale_err"].append(1.0 - scale_iou(pred
|
|
125
|
+
match_data["trans_err"].append(center_distance(pred.translation, gt.translation))
|
|
126
|
+
match_data["scale_err"].append(1.0 - scale_iou(pred.size, gt.size))
|
|
129
127
|
period = np.pi if class_name == "barrier" else 2 * np.pi
|
|
130
|
-
match_data["orient_err"].append(yaw_diff(pred
|
|
131
|
-
if pred
|
|
128
|
+
match_data["orient_err"].append(yaw_diff(pred.rotation, gt.rotation, period=period))
|
|
129
|
+
if pred.velocity is not None and gt.velocity is not None:
|
|
132
130
|
vel_diff = float(np.linalg.norm(
|
|
133
|
-
|
|
131
|
+
pred.velocity[:2] - gt.velocity[:2]
|
|
134
132
|
))
|
|
135
133
|
match_data["vel_err"].append(vel_diff)
|
|
136
134
|
else:
|
|
137
135
|
match_data["vel_err"].append(float("nan"))
|
|
138
|
-
match_data["attr_err"].append(1.0 - attr_acc(gt
|
|
136
|
+
match_data["attr_err"].append(1.0 - attr_acc(gt.attributes, pred.attributes))
|
|
139
137
|
else:
|
|
140
138
|
for key in match_data:
|
|
141
139
|
match_data[key].append(float("nan"))
|
|
142
140
|
|
|
143
|
-
# Compute precision / recall
|
|
144
141
|
tp_arr = np.array(tp_list, dtype=float)
|
|
145
142
|
conf_arr = np.array(conf_list, dtype=float)
|
|
146
143
|
tp_cum = np.cumsum(tp_arr)
|
|
@@ -148,7 +145,6 @@ def accumulate(
|
|
|
148
145
|
precision_raw = tp_cum / (tp_cum + fp_cum)
|
|
149
146
|
recall_raw = tp_cum / npos
|
|
150
147
|
|
|
151
|
-
# Interpolate to NELEM recall points
|
|
152
148
|
recall_interp = np.linspace(0, 1, NELEM)
|
|
153
149
|
precision_interp = np.zeros(NELEM)
|
|
154
150
|
confidence_interp = np.zeros(NELEM)
|
|
@@ -159,7 +155,6 @@ def accumulate(
|
|
|
159
155
|
precision_interp[i] = precision_raw[mask].max()
|
|
160
156
|
confidence_interp[i] = conf_arr[mask].min()
|
|
161
157
|
|
|
162
|
-
# Interpolate TP errors using cumulative mean
|
|
163
158
|
tp_metric_interp = {}
|
|
164
159
|
for key in match_data:
|
|
165
160
|
raw = np.array(match_data[key], dtype=float)
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
LICENSE
|
|
2
2
|
README.md
|
|
3
3
|
pyproject.toml
|
|
4
|
+
tests/test_box.py
|
|
4
5
|
tests/test_coordinate_frames.py
|
|
5
6
|
tests/test_detection_eval.py
|
|
7
|
+
tests/test_evaluate.py
|
|
6
8
|
tests/test_fusion.py
|
|
7
9
|
tests/test_loader.py
|
|
8
10
|
tests/test_matching.py
|
|
@@ -37,6 +39,8 @@ x4d_devkit/core/models.py
|
|
|
37
39
|
x4d_devkit/core/token.py
|
|
38
40
|
x4d_devkit/core/transform.py
|
|
39
41
|
x4d_devkit/eval/__init__.py
|
|
42
|
+
x4d_devkit/eval/api.py
|
|
43
|
+
x4d_devkit/eval/box.py
|
|
40
44
|
x4d_devkit/eval/detection.py
|
|
41
45
|
x4d_devkit/eval/matching.py
|
|
42
46
|
x4d_devkit/eval/metrics.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|