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.
Files changed (51) hide show
  1. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/PKG-INFO +1 -1
  2. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/pyproject.toml +1 -1
  3. x4d_devkit-0.8.0/tests/test_box.py +121 -0
  4. x4d_devkit-0.8.0/tests/test_evaluate.py +154 -0
  5. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/tests/test_matching.py +11 -10
  6. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/__init__.py +6 -0
  7. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/eval/__init__.py +5 -0
  8. x4d_devkit-0.8.0/x4d_devkit/eval/api.py +205 -0
  9. x4d_devkit-0.8.0/x4d_devkit/eval/box.py +124 -0
  10. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/eval/detection.py +8 -114
  11. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/eval/matching.py +28 -33
  12. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit.egg-info/PKG-INFO +1 -1
  13. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit.egg-info/SOURCES.txt +4 -0
  14. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/LICENSE +0 -0
  15. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/README.md +0 -0
  16. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/setup.cfg +0 -0
  17. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/tests/test_coordinate_frames.py +0 -0
  18. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/tests/test_detection_eval.py +0 -0
  19. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/tests/test_fusion.py +0 -0
  20. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/tests/test_loader.py +0 -0
  21. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/tests/test_metrics.py +0 -0
  22. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/tests/test_models.py +0 -0
  23. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/tests/test_nuscenes_converter.py +0 -0
  24. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/tests/test_token.py +0 -0
  25. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/tests/test_transform.py +0 -0
  26. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/tests/test_transform_chain.py +0 -0
  27. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/tests/test_v06_api_additions.py +0 -0
  28. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/tests/test_validation.py +0 -0
  29. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/cli.py +0 -0
  30. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/client/__init__.py +0 -0
  31. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/client/checkpoints.py +0 -0
  32. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/client/client.py +0 -0
  33. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/client/clips.py +0 -0
  34. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/client/projects.py +0 -0
  35. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/client/test_sets.py +0 -0
  36. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/converters/__init__.py +0 -0
  37. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/converters/nuscenes.py +0 -0
  38. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/core/__init__.py +0 -0
  39. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/core/loader.py +0 -0
  40. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/core/models.py +0 -0
  41. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/core/token.py +0 -0
  42. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/core/transform.py +0 -0
  43. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/eval/metrics.py +0 -0
  44. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/fusion.py +0 -0
  45. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/validation/__init__.py +0 -0
  46. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/validation/report.py +0 -0
  47. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit/validation/validator.py +0 -0
  48. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit.egg-info/dependency_links.txt +0 -0
  49. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit.egg-info/entry_points.txt +0 -0
  50. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit.egg-info/requires.txt +0 -0
  51. {x4d_devkit-0.7.0 → x4d_devkit-0.8.0}/x4d_devkit.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: x4d-devkit
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: X-4D dataset format SDK — load, validate, evaluate, and convert autonomous driving datasets
5
5
  Author: windzu
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "x4d-devkit"
7
- version = "0.7.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 create a dict that looks like what accumulate() expects."""
13
- return {
14
- "translation": np.asarray(translation, dtype=float),
15
- "size": np.asarray(size, dtype=float),
16
- "rotation": np.asarray(rotation, dtype=float),
17
- "category": category,
18
- "score": score,
19
- "velocity": np.asarray(velocity, dtype=float) if velocity is not None else None,
20
- "attributes": attributes or [],
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, accumulate
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
- cfg = self._config
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[dict]] = {}
315
- all_pred: dict[str, list[dict]] = {}
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
- gt_boxes = _extract_boxes(gt_clip, cfg)
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
- pred_boxes = _extract_boxes(pred_clip, cfg, max_boxes=cfg.max_boxes_per_sample)
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
- for st in all_gt:
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[dict]],
59
- pred_boxes: dict[str, list[dict]],
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: [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").
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
- # Filter by class
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["category"] == class_name]
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[dict]] = {}
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["category"] == class_name]
89
- class_boxes.sort(key=lambda b: b["score"], reverse=True)
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["score"], sample_token, i))
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
- # 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"]}
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["translation"], gt["translation"])
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["translation"], gt["translation"]))
128
- match_data["scale_err"].append(1.0 - scale_iou(pred["size"], gt["size"]))
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["rotation"], gt["rotation"], period=period))
131
- if pred["velocity"] is not None and gt["velocity"] is not None:
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
- np.asarray(pred["velocity"])[:2] - np.asarray(gt["velocity"])[:2]
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["attributes"], pred["attributes"]))
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: x4d-devkit
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: X-4D dataset format SDK — load, validate, evaluate, and convert autonomous driving datasets
5
5
  Author: windzu
6
6
  License-Expression: Apache-2.0
@@ -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