x4d-devkit 0.6.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.6.0 → x4d_devkit-0.8.0}/PKG-INFO +1 -1
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/pyproject.toml +1 -1
- x4d_devkit-0.8.0/tests/test_box.py +121 -0
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/tests/test_coordinate_frames.py +5 -2
- x4d_devkit-0.8.0/tests/test_evaluate.py +154 -0
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/tests/test_matching.py +11 -10
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/tests/test_models.py +79 -2
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/x4d_devkit/__init__.py +10 -0
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/x4d_devkit/core/__init__.py +4 -0
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/x4d_devkit/core/loader.py +12 -3
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/x4d_devkit/core/models.py +97 -6
- {x4d_devkit-0.6.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.6.0 → x4d_devkit-0.8.0}/x4d_devkit/eval/detection.py +8 -114
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/x4d_devkit/eval/matching.py +28 -33
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/x4d_devkit.egg-info/PKG-INFO +1 -1
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/x4d_devkit.egg-info/SOURCES.txt +4 -0
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/LICENSE +0 -0
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/README.md +0 -0
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/setup.cfg +0 -0
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/tests/test_detection_eval.py +0 -0
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/tests/test_fusion.py +0 -0
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/tests/test_loader.py +0 -0
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/tests/test_metrics.py +0 -0
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/tests/test_nuscenes_converter.py +0 -0
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/tests/test_token.py +0 -0
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/tests/test_transform.py +0 -0
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/tests/test_transform_chain.py +0 -0
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/tests/test_v06_api_additions.py +0 -0
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/tests/test_validation.py +0 -0
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/x4d_devkit/cli.py +0 -0
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/x4d_devkit/client/__init__.py +0 -0
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/x4d_devkit/client/checkpoints.py +0 -0
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/x4d_devkit/client/client.py +0 -0
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/x4d_devkit/client/clips.py +0 -0
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/x4d_devkit/client/projects.py +0 -0
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/x4d_devkit/client/test_sets.py +0 -0
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/x4d_devkit/converters/__init__.py +0 -0
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/x4d_devkit/converters/nuscenes.py +0 -0
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/x4d_devkit/core/token.py +0 -0
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/x4d_devkit/core/transform.py +0 -0
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/x4d_devkit/eval/metrics.py +0 -0
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/x4d_devkit/fusion.py +0 -0
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/x4d_devkit/validation/__init__.py +0 -0
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/x4d_devkit/validation/report.py +0 -0
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/x4d_devkit/validation/validator.py +0 -0
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/x4d_devkit.egg-info/dependency_links.txt +0 -0
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/x4d_devkit.egg-info/entry_points.txt +0 -0
- {x4d_devkit-0.6.0 → x4d_devkit-0.8.0}/x4d_devkit.egg-info/requires.txt +0 -0
- {x4d_devkit-0.6.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"]
|
|
@@ -38,11 +38,14 @@ def _make_synthetic_clip(tmp_path: Path) -> Path:
|
|
|
38
38
|
"sensors": {
|
|
39
39
|
"LIDAR_TOP": {
|
|
40
40
|
"modality": "lidar",
|
|
41
|
-
"point_format": {
|
|
41
|
+
"point_format": {
|
|
42
|
+
"fields": ["x", "y", "z", "intensity"],
|
|
43
|
+
"types": ["float32"] * 4,
|
|
44
|
+
"bytes_per_point": 16,
|
|
45
|
+
},
|
|
42
46
|
},
|
|
43
47
|
"CAM_FRONT": {
|
|
44
48
|
"modality": "camera",
|
|
45
|
-
"point_format": None
|
|
46
49
|
}
|
|
47
50
|
}
|
|
48
51
|
}))
|
|
@@ -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:
|
|
@@ -9,6 +9,8 @@ from x4d_devkit.core.models import (
|
|
|
9
9
|
SampleData,
|
|
10
10
|
Annotation,
|
|
11
11
|
Instance,
|
|
12
|
+
PointFormat,
|
|
13
|
+
SensorInfo,
|
|
12
14
|
)
|
|
13
15
|
|
|
14
16
|
|
|
@@ -16,17 +18,21 @@ class TestClipMeta:
|
|
|
16
18
|
def test_construction(self):
|
|
17
19
|
meta = ClipMeta(
|
|
18
20
|
clip_id="20180724_n015_scene-0061_000",
|
|
19
|
-
schema_version="0.
|
|
21
|
+
schema_version="0.5",
|
|
20
22
|
session_id="n015-2018-07-24-11-22-45+0800",
|
|
21
23
|
vehicle_id="n015",
|
|
22
24
|
capture_time_utc="2018-07-24T00:00:00Z",
|
|
23
25
|
duration_s=19.5,
|
|
24
26
|
num_keyframes=39,
|
|
25
27
|
num_sweeps=305,
|
|
26
|
-
sensors={
|
|
28
|
+
sensors={
|
|
29
|
+
"CAM_FRONT": SensorInfo(modality="camera", resolution=(1600, 900)),
|
|
30
|
+
},
|
|
27
31
|
)
|
|
28
32
|
assert meta.clip_id == "20180724_n015_scene-0061_000"
|
|
29
33
|
assert meta.num_keyframes == 39
|
|
34
|
+
assert meta.sensors["CAM_FRONT"].modality == "camera"
|
|
35
|
+
assert meta.sensors["CAM_FRONT"].resolution == (1600, 900)
|
|
30
36
|
|
|
31
37
|
def test_frozen(self):
|
|
32
38
|
meta = ClipMeta(
|
|
@@ -153,3 +159,74 @@ class TestInstance:
|
|
|
153
159
|
first_annotation_token="a1", last_annotation_token="a5",
|
|
154
160
|
)
|
|
155
161
|
assert inst.num_annotations == 5
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class TestPointFormat:
|
|
165
|
+
def test_construction_normalises_to_tuples(self):
|
|
166
|
+
pf = PointFormat(
|
|
167
|
+
fields=["x", "y", "z", "intensity", "time"],
|
|
168
|
+
types=["float32"] * 5,
|
|
169
|
+
bytes_per_point=20,
|
|
170
|
+
)
|
|
171
|
+
assert isinstance(pf.fields, tuple)
|
|
172
|
+
assert isinstance(pf.types, tuple)
|
|
173
|
+
assert pf.num_fields == 5
|
|
174
|
+
|
|
175
|
+
def test_mismatched_lengths_rejected(self):
|
|
176
|
+
with pytest.raises(ValueError, match="must have equal length"):
|
|
177
|
+
PointFormat(fields=["x", "y", "z"], types=["float32", "float32"], bytes_per_point=12)
|
|
178
|
+
|
|
179
|
+
def test_round_trip_via_dict(self):
|
|
180
|
+
d = {
|
|
181
|
+
"fields": ["x", "y", "z", "intensity", "time"],
|
|
182
|
+
"types": ["float32"] * 5,
|
|
183
|
+
"bytes_per_point": 20,
|
|
184
|
+
}
|
|
185
|
+
pf = PointFormat.from_dict(d)
|
|
186
|
+
assert pf.to_dict() == d
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class TestSensorInfo:
|
|
190
|
+
def test_lidar_with_point_format(self):
|
|
191
|
+
d = {
|
|
192
|
+
"modality": "lidar",
|
|
193
|
+
"point_format": {
|
|
194
|
+
"fields": ["x", "y", "z", "intensity", "time"],
|
|
195
|
+
"types": ["float32"] * 5,
|
|
196
|
+
"bytes_per_point": 20,
|
|
197
|
+
},
|
|
198
|
+
}
|
|
199
|
+
si = SensorInfo.from_dict(d)
|
|
200
|
+
assert si.modality == "lidar"
|
|
201
|
+
assert si.point_format is not None
|
|
202
|
+
assert si.point_format.num_fields == 5
|
|
203
|
+
assert si.resolution is None
|
|
204
|
+
assert si.to_dict() == d
|
|
205
|
+
|
|
206
|
+
def test_camera_with_resolution(self):
|
|
207
|
+
d = {"modality": "camera", "resolution": [1600, 900]}
|
|
208
|
+
si = SensorInfo.from_dict(d)
|
|
209
|
+
assert si.modality == "camera"
|
|
210
|
+
assert si.point_format is None
|
|
211
|
+
assert si.resolution == (1600, 900)
|
|
212
|
+
assert si.to_dict() == d
|
|
213
|
+
|
|
214
|
+
def test_extras_round_trip(self):
|
|
215
|
+
"""Unknown keys (e.g. producer-specific extras) survive a round-trip."""
|
|
216
|
+
d = {
|
|
217
|
+
"modality": "lidar",
|
|
218
|
+
"point_format": {
|
|
219
|
+
"fields": ["x", "y", "z"],
|
|
220
|
+
"types": ["float32", "float32", "float32"],
|
|
221
|
+
"bytes_per_point": 12,
|
|
222
|
+
},
|
|
223
|
+
"fused_from": ["lidar_top", "lidar_left"],
|
|
224
|
+
}
|
|
225
|
+
si = SensorInfo.from_dict(d)
|
|
226
|
+
assert si.extras == {"fused_from": ["lidar_top", "lidar_left"]}
|
|
227
|
+
out = si.to_dict()
|
|
228
|
+
assert out["fused_from"] == ["lidar_top", "lidar_left"]
|
|
229
|
+
|
|
230
|
+
def test_camera_no_point_format_in_dict(self):
|
|
231
|
+
si = SensorInfo.from_dict({"modality": "camera"})
|
|
232
|
+
assert si.point_format is None
|
|
@@ -15,19 +15,24 @@ from x4d_devkit.core import (
|
|
|
15
15
|
ClipMeta,
|
|
16
16
|
EgoPose,
|
|
17
17
|
Instance,
|
|
18
|
+
PointFormat,
|
|
18
19
|
Sample,
|
|
19
20
|
SampleData,
|
|
20
21
|
SchemaVersionMismatch,
|
|
22
|
+
SensorInfo,
|
|
21
23
|
Transform,
|
|
22
24
|
TransformChain,
|
|
23
25
|
generate_token,
|
|
24
26
|
)
|
|
25
27
|
from x4d_devkit.eval import (
|
|
28
|
+
Box,
|
|
26
29
|
DetectionConfig,
|
|
27
30
|
DetectionEval,
|
|
28
31
|
DetectionResult,
|
|
32
|
+
boxes_from_clip,
|
|
29
33
|
calc_ap,
|
|
30
34
|
calc_tp,
|
|
35
|
+
evaluate,
|
|
31
36
|
nusc_detection_config,
|
|
32
37
|
)
|
|
33
38
|
from x4d_devkit.validation import validate_clip, ValidationReport
|
|
@@ -36,6 +41,7 @@ from x4d_devkit.fusion import LIDAR_FUSED_DTYPE, FusionSource, fuse_pointclouds
|
|
|
36
41
|
__all__ = [
|
|
37
42
|
"__version__",
|
|
38
43
|
"Annotation",
|
|
44
|
+
"Box",
|
|
39
45
|
"CalibratedSensor",
|
|
40
46
|
"LIDAR_FUSED_DTYPE",
|
|
41
47
|
"FusionSource",
|
|
@@ -47,15 +53,19 @@ __all__ = [
|
|
|
47
53
|
"DetectionResult",
|
|
48
54
|
"EgoPose",
|
|
49
55
|
"Instance",
|
|
56
|
+
"PointFormat",
|
|
50
57
|
"Sample",
|
|
51
58
|
"SampleData",
|
|
52
59
|
"SchemaVersionMismatch",
|
|
60
|
+
"SensorInfo",
|
|
53
61
|
"Transform",
|
|
54
62
|
"TransformChain",
|
|
55
63
|
"ValidationReport",
|
|
56
64
|
"X4DClient",
|
|
65
|
+
"boxes_from_clip",
|
|
57
66
|
"calc_ap",
|
|
58
67
|
"calc_tp",
|
|
68
|
+
"evaluate",
|
|
59
69
|
"generate_token",
|
|
60
70
|
"nusc_detection_config",
|
|
61
71
|
"validate_clip",
|
|
@@ -7,8 +7,10 @@ from x4d_devkit.core.models import (
|
|
|
7
7
|
ClipMeta,
|
|
8
8
|
EgoPose,
|
|
9
9
|
Instance,
|
|
10
|
+
PointFormat,
|
|
10
11
|
Sample,
|
|
11
12
|
SampleData,
|
|
13
|
+
SensorInfo,
|
|
12
14
|
)
|
|
13
15
|
from x4d_devkit.core.token import generate_token
|
|
14
16
|
from x4d_devkit.core.transform import Transform, TransformChain
|
|
@@ -20,9 +22,11 @@ __all__ = [
|
|
|
20
22
|
"ClipMeta",
|
|
21
23
|
"EgoPose",
|
|
22
24
|
"Instance",
|
|
25
|
+
"PointFormat",
|
|
23
26
|
"Sample",
|
|
24
27
|
"SampleData",
|
|
25
28
|
"SchemaVersionMismatch",
|
|
29
|
+
"SensorInfo",
|
|
26
30
|
"Transform",
|
|
27
31
|
"TransformChain",
|
|
28
32
|
"generate_token",
|
|
@@ -17,6 +17,7 @@ from x4d_devkit.core.models import (
|
|
|
17
17
|
SampleData,
|
|
18
18
|
Annotation,
|
|
19
19
|
Instance,
|
|
20
|
+
SensorInfo,
|
|
20
21
|
)
|
|
21
22
|
from x4d_devkit.core.transform import Transform, TransformChain, rotation_matrix_to_quat
|
|
22
23
|
|
|
@@ -122,6 +123,10 @@ class ClipLoader:
|
|
|
122
123
|
|
|
123
124
|
def _load_meta(self) -> ClipMeta:
|
|
124
125
|
d = self._read_json("meta.json")
|
|
126
|
+
sensors = {
|
|
127
|
+
channel: SensorInfo.from_dict(raw)
|
|
128
|
+
for channel, raw in (d.get("sensors") or {}).items()
|
|
129
|
+
}
|
|
125
130
|
return ClipMeta(
|
|
126
131
|
clip_id=d["clip_id"],
|
|
127
132
|
schema_version=d["schema_version"],
|
|
@@ -131,7 +136,7 @@ class ClipLoader:
|
|
|
131
136
|
duration_s=d["duration_s"],
|
|
132
137
|
num_keyframes=d["keyframe_count"],
|
|
133
138
|
num_sweeps=d["sweep_count"],
|
|
134
|
-
sensors=
|
|
139
|
+
sensors=sensors,
|
|
135
140
|
)
|
|
136
141
|
|
|
137
142
|
def _load_calibrated_sensors(self) -> list[CalibratedSensor]:
|
|
@@ -400,8 +405,12 @@ class ClipLoader:
|
|
|
400
405
|
raise ValueError(f"Invalid frame '{frame}', must be one of {_VALID_FRAMES}")
|
|
401
406
|
|
|
402
407
|
sensor_info = self._meta.sensors[sd.channel]
|
|
403
|
-
|
|
404
|
-
|
|
408
|
+
if sensor_info.point_format is None:
|
|
409
|
+
raise ValueError(
|
|
410
|
+
f"channel '{sd.channel}' has no point_format in meta.json — "
|
|
411
|
+
f"cannot load as a point cloud (modality={sensor_info.modality})"
|
|
412
|
+
)
|
|
413
|
+
num_fields = sensor_info.point_format.num_fields
|
|
405
414
|
|
|
406
415
|
path = self._clip_dir / sd.file_path
|
|
407
416
|
raw = np.fromfile(str(path), dtype=np.float32)
|