x4d-devkit 0.5.1__tar.gz → 0.7.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.5.1/x4d_devkit.egg-info → x4d_devkit-0.7.0}/PKG-INFO +1 -1
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/pyproject.toml +1 -1
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/tests/test_coordinate_frames.py +5 -2
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/tests/test_models.py +79 -2
- x4d_devkit-0.7.0/tests/test_v06_api_additions.py +162 -0
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/__init__.py +18 -0
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/core/__init__.py +6 -1
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/core/loader.py +63 -4
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/core/models.py +97 -6
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/eval/__init__.py +4 -0
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/eval/detection.py +9 -4
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0/x4d_devkit.egg-info}/PKG-INFO +1 -1
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit.egg-info/SOURCES.txt +1 -0
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/LICENSE +0 -0
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/README.md +0 -0
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/setup.cfg +0 -0
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/tests/test_detection_eval.py +0 -0
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/tests/test_fusion.py +0 -0
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/tests/test_loader.py +0 -0
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/tests/test_matching.py +0 -0
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/tests/test_metrics.py +0 -0
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/tests/test_nuscenes_converter.py +0 -0
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/tests/test_token.py +0 -0
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/tests/test_transform.py +0 -0
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/tests/test_transform_chain.py +0 -0
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/tests/test_validation.py +0 -0
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/cli.py +0 -0
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/client/__init__.py +0 -0
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/client/checkpoints.py +0 -0
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/client/client.py +0 -0
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/client/clips.py +0 -0
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/client/projects.py +0 -0
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/client/test_sets.py +0 -0
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/converters/__init__.py +0 -0
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/converters/nuscenes.py +0 -0
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/core/token.py +0 -0
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/core/transform.py +0 -0
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/eval/matching.py +0 -0
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/eval/metrics.py +0 -0
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/fusion.py +0 -0
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/validation/__init__.py +0 -0
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/validation/report.py +0 -0
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/validation/validator.py +0 -0
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit.egg-info/dependency_links.txt +0 -0
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit.egg-info/entry_points.txt +0 -0
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit.egg-info/requires.txt +0 -0
- {x4d_devkit-0.5.1 → x4d_devkit-0.7.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.7.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"
|
|
@@ -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
|
}))
|
|
@@ -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
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Unit tests for v0.6.0 API additions.
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- x4d_devkit.__version__
|
|
5
|
+
- ClipLoader.schema_version property + require_schema_version + constructor kwarg
|
|
6
|
+
- SchemaVersionMismatch exception
|
|
7
|
+
- calc_ap / calc_tp public re-exports (and underscore aliases still work)
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
import x4d_devkit
|
|
18
|
+
from x4d_devkit import (
|
|
19
|
+
ClipLoader,
|
|
20
|
+
SchemaVersionMismatch,
|
|
21
|
+
calc_ap,
|
|
22
|
+
calc_tp,
|
|
23
|
+
)
|
|
24
|
+
from x4d_devkit.eval.matching import MetricData
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _write_minimal_clip(clip_dir: Path, schema_version: str) -> None:
|
|
28
|
+
"""Write the minimum JSON files ClipLoader.__init__ needs to succeed."""
|
|
29
|
+
clip_dir.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
meta = {
|
|
31
|
+
"clip_id": "20260504_test_001",
|
|
32
|
+
"schema_version": schema_version,
|
|
33
|
+
"session_id": "20260504_test",
|
|
34
|
+
"vehicle_id": "test",
|
|
35
|
+
"capture_time_utc": "2026-05-04T00:00:00Z",
|
|
36
|
+
"duration_s": 0.0,
|
|
37
|
+
"keyframe_count": 0,
|
|
38
|
+
"sweep_count": 0,
|
|
39
|
+
"sensors": [],
|
|
40
|
+
}
|
|
41
|
+
(clip_dir / "meta.json").write_text(json.dumps(meta))
|
|
42
|
+
for empty in (
|
|
43
|
+
"calibrated_sensor.json",
|
|
44
|
+
"ego_pose.json",
|
|
45
|
+
"sample.json",
|
|
46
|
+
"sample_data.json",
|
|
47
|
+
"annotation.json",
|
|
48
|
+
"instance.json",
|
|
49
|
+
):
|
|
50
|
+
(clip_dir / empty).write_text("[]")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_package_has_version_attribute():
|
|
54
|
+
"""__version__ is a non-empty string matching expected shape."""
|
|
55
|
+
assert isinstance(x4d_devkit.__version__, str)
|
|
56
|
+
assert x4d_devkit.__version__ # non-empty
|
|
57
|
+
# Either a real version (e.g. "0.6.0", "0.6.0.post1") or the editable fallback.
|
|
58
|
+
assert "." in x4d_devkit.__version__ or x4d_devkit.__version__.startswith("0.0.0")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_loader_schema_version_property(tmp_path):
|
|
62
|
+
_write_minimal_clip(tmp_path, "0.5")
|
|
63
|
+
loader = ClipLoader(tmp_path)
|
|
64
|
+
assert loader.schema_version == "0.5"
|
|
65
|
+
assert loader.schema_version == loader.meta.schema_version
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_loader_require_schema_version_match(tmp_path):
|
|
69
|
+
_write_minimal_clip(tmp_path, "0.5")
|
|
70
|
+
loader = ClipLoader(tmp_path)
|
|
71
|
+
loader.require_schema_version("0.5") # no raise
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_loader_require_schema_version_mismatch(tmp_path):
|
|
75
|
+
_write_minimal_clip(tmp_path, "0.4")
|
|
76
|
+
loader = ClipLoader(tmp_path)
|
|
77
|
+
with pytest.raises(SchemaVersionMismatch) as exc:
|
|
78
|
+
loader.require_schema_version("0.5")
|
|
79
|
+
assert exc.value.actual == "0.4"
|
|
80
|
+
assert exc.value.expected == "0.5"
|
|
81
|
+
assert "0.4" in str(exc.value) and "0.5" in str(exc.value)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_loader_require_schema_version_set(tmp_path):
|
|
85
|
+
_write_minimal_clip(tmp_path, "0.4")
|
|
86
|
+
loader = ClipLoader(tmp_path)
|
|
87
|
+
loader.require_schema_version({"0.4", "0.5"}) # no raise
|
|
88
|
+
with pytest.raises(SchemaVersionMismatch):
|
|
89
|
+
loader.require_schema_version({"0.5", "0.6"})
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_loader_constructor_strict_check_match(tmp_path):
|
|
93
|
+
_write_minimal_clip(tmp_path, "0.5")
|
|
94
|
+
ClipLoader(tmp_path, expected_schema_version="0.5") # no raise
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_loader_constructor_strict_check_mismatch(tmp_path):
|
|
98
|
+
_write_minimal_clip(tmp_path, "0.4")
|
|
99
|
+
with pytest.raises(SchemaVersionMismatch):
|
|
100
|
+
ClipLoader(tmp_path, expected_schema_version="0.5")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_loader_constructor_strict_check_set(tmp_path):
|
|
104
|
+
_write_minimal_clip(tmp_path, "0.5")
|
|
105
|
+
ClipLoader(tmp_path, expected_schema_version={"0.4", "0.5"}) # no raise
|
|
106
|
+
_write_minimal_clip(tmp_path, "0.3")
|
|
107
|
+
with pytest.raises(SchemaVersionMismatch):
|
|
108
|
+
ClipLoader(tmp_path, expected_schema_version={"0.4", "0.5"})
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _md_with_precision(prec: np.ndarray) -> MetricData:
|
|
112
|
+
"""Build a MetricData with just enough fields for calc_ap / calc_tp."""
|
|
113
|
+
n = len(prec)
|
|
114
|
+
md = MetricData(
|
|
115
|
+
recall=np.linspace(0, 1, n),
|
|
116
|
+
precision=prec,
|
|
117
|
+
confidence=np.linspace(1, 0, n),
|
|
118
|
+
trans_err=np.zeros(n),
|
|
119
|
+
vel_err=np.zeros(n),
|
|
120
|
+
scale_err=np.zeros(n),
|
|
121
|
+
orient_err=np.zeros(n),
|
|
122
|
+
attr_err=np.zeros(n),
|
|
123
|
+
)
|
|
124
|
+
return md
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_calc_ap_public_export():
|
|
128
|
+
"""calc_ap is importable from x4d_devkit and x4d_devkit.eval, returns expected value."""
|
|
129
|
+
from x4d_devkit.eval import calc_ap as eval_calc_ap
|
|
130
|
+
|
|
131
|
+
assert eval_calc_ap is calc_ap
|
|
132
|
+
|
|
133
|
+
# Constant precision = 1.0 across recall, min_recall=0, min_precision=0 → AP=1.0
|
|
134
|
+
md = _md_with_precision(np.ones(101))
|
|
135
|
+
ap = calc_ap(md, min_recall=0.0, min_precision=0.0)
|
|
136
|
+
assert ap == pytest.approx(1.0)
|
|
137
|
+
|
|
138
|
+
# Constant 0 precision → AP=0.0
|
|
139
|
+
md = _md_with_precision(np.zeros(101))
|
|
140
|
+
ap = calc_ap(md, min_recall=0.0, min_precision=0.0)
|
|
141
|
+
assert ap == pytest.approx(0.0)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_calc_tp_public_export():
|
|
145
|
+
"""calc_tp is importable and returns mean of metric in valid recall range."""
|
|
146
|
+
from x4d_devkit.eval import calc_tp as eval_calc_tp
|
|
147
|
+
|
|
148
|
+
assert eval_calc_tp is calc_tp
|
|
149
|
+
|
|
150
|
+
md = _md_with_precision(np.ones(101))
|
|
151
|
+
md.trans_err[:] = 0.5
|
|
152
|
+
# min_recall=0 → first_ind=1; max_recall_ind defaults; mean over [1..max] of 0.5 = 0.5
|
|
153
|
+
tp = calc_tp(md, min_recall=0.0, metric_name="trans_err")
|
|
154
|
+
assert tp == pytest.approx(0.5)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_underscore_aliases_still_work():
|
|
158
|
+
"""Old _calc_ap / _calc_tp names remain importable (deprecated alias)."""
|
|
159
|
+
from x4d_devkit.eval.detection import _calc_ap, _calc_tp
|
|
160
|
+
|
|
161
|
+
assert _calc_ap is calc_ap
|
|
162
|
+
assert _calc_tp is calc_tp
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
"""X-4D dataset format SDK."""
|
|
2
2
|
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = _pkg_version("x4d-devkit")
|
|
7
|
+
except PackageNotFoundError: # source checkout without installed metadata
|
|
8
|
+
__version__ = "0.0.0+unknown"
|
|
9
|
+
|
|
3
10
|
from x4d_devkit.client import X4DClient
|
|
4
11
|
from x4d_devkit.core import (
|
|
5
12
|
Annotation,
|
|
@@ -8,8 +15,11 @@ from x4d_devkit.core import (
|
|
|
8
15
|
ClipMeta,
|
|
9
16
|
EgoPose,
|
|
10
17
|
Instance,
|
|
18
|
+
PointFormat,
|
|
11
19
|
Sample,
|
|
12
20
|
SampleData,
|
|
21
|
+
SchemaVersionMismatch,
|
|
22
|
+
SensorInfo,
|
|
13
23
|
Transform,
|
|
14
24
|
TransformChain,
|
|
15
25
|
generate_token,
|
|
@@ -18,12 +28,15 @@ from x4d_devkit.eval import (
|
|
|
18
28
|
DetectionConfig,
|
|
19
29
|
DetectionEval,
|
|
20
30
|
DetectionResult,
|
|
31
|
+
calc_ap,
|
|
32
|
+
calc_tp,
|
|
21
33
|
nusc_detection_config,
|
|
22
34
|
)
|
|
23
35
|
from x4d_devkit.validation import validate_clip, ValidationReport
|
|
24
36
|
from x4d_devkit.fusion import LIDAR_FUSED_DTYPE, FusionSource, fuse_pointclouds # noqa: E402,F401
|
|
25
37
|
|
|
26
38
|
__all__ = [
|
|
39
|
+
"__version__",
|
|
27
40
|
"Annotation",
|
|
28
41
|
"CalibratedSensor",
|
|
29
42
|
"LIDAR_FUSED_DTYPE",
|
|
@@ -36,12 +49,17 @@ __all__ = [
|
|
|
36
49
|
"DetectionResult",
|
|
37
50
|
"EgoPose",
|
|
38
51
|
"Instance",
|
|
52
|
+
"PointFormat",
|
|
39
53
|
"Sample",
|
|
40
54
|
"SampleData",
|
|
55
|
+
"SchemaVersionMismatch",
|
|
56
|
+
"SensorInfo",
|
|
41
57
|
"Transform",
|
|
42
58
|
"TransformChain",
|
|
43
59
|
"ValidationReport",
|
|
44
60
|
"X4DClient",
|
|
61
|
+
"calc_ap",
|
|
62
|
+
"calc_tp",
|
|
45
63
|
"generate_token",
|
|
46
64
|
"nusc_detection_config",
|
|
47
65
|
"validate_clip",
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
"""Core layer: data models, clip loader, transforms."""
|
|
2
2
|
|
|
3
|
-
from x4d_devkit.core.loader import ClipLoader
|
|
3
|
+
from x4d_devkit.core.loader import ClipLoader, SchemaVersionMismatch
|
|
4
4
|
from x4d_devkit.core.models import (
|
|
5
5
|
Annotation,
|
|
6
6
|
CalibratedSensor,
|
|
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,8 +22,11 @@ __all__ = [
|
|
|
20
22
|
"ClipMeta",
|
|
21
23
|
"EgoPose",
|
|
22
24
|
"Instance",
|
|
25
|
+
"PointFormat",
|
|
23
26
|
"Sample",
|
|
24
27
|
"SampleData",
|
|
28
|
+
"SchemaVersionMismatch",
|
|
29
|
+
"SensorInfo",
|
|
25
30
|
"Transform",
|
|
26
31
|
"TransformChain",
|
|
27
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
|
|
|
@@ -24,6 +25,24 @@ FrameType = Literal["sensor", "ego", "world"]
|
|
|
24
25
|
_VALID_FRAMES = {"sensor", "ego", "world"}
|
|
25
26
|
|
|
26
27
|
|
|
28
|
+
class SchemaVersionMismatch(ValueError):
|
|
29
|
+
"""Raised when a clip's ``meta.schema_version`` doesn't match the consumer's expectations.
|
|
30
|
+
|
|
31
|
+
Attributes:
|
|
32
|
+
actual: the version found in ``meta.json``.
|
|
33
|
+
expected: the version (or set of versions) the consumer required.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, actual: str, expected: "str | set[str]") -> None:
|
|
37
|
+
self.actual = actual
|
|
38
|
+
self.expected = expected
|
|
39
|
+
if isinstance(expected, str):
|
|
40
|
+
msg = f"clip schema_version is {actual!r}, expected {expected!r}"
|
|
41
|
+
else:
|
|
42
|
+
msg = f"clip schema_version is {actual!r}, expected one of {sorted(expected)!r}"
|
|
43
|
+
super().__init__(msg)
|
|
44
|
+
|
|
45
|
+
|
|
27
46
|
class ClipLoader:
|
|
28
47
|
"""Load and index an X4D clip directory.
|
|
29
48
|
|
|
@@ -49,11 +68,18 @@ class ClipLoader:
|
|
|
49
68
|
anns = loader.annotations_for_sample(sample.token, frame="ego")
|
|
50
69
|
"""
|
|
51
70
|
|
|
52
|
-
def __init__(
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
clip_dir: str | Path,
|
|
74
|
+
*,
|
|
75
|
+
expected_schema_version: "str | set[str] | None" = None,
|
|
76
|
+
) -> None:
|
|
53
77
|
self._clip_dir = Path(clip_dir)
|
|
54
78
|
|
|
55
79
|
# Load and parse all JSON
|
|
56
80
|
self._meta = self._load_meta()
|
|
81
|
+
if expected_schema_version is not None:
|
|
82
|
+
self.require_schema_version(expected_schema_version)
|
|
57
83
|
self._calibrated_sensors = self._load_calibrated_sensors()
|
|
58
84
|
self._ego_poses = self._load_ego_poses()
|
|
59
85
|
self._samples = self._load_samples()
|
|
@@ -97,6 +123,10 @@ class ClipLoader:
|
|
|
97
123
|
|
|
98
124
|
def _load_meta(self) -> ClipMeta:
|
|
99
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
|
+
}
|
|
100
130
|
return ClipMeta(
|
|
101
131
|
clip_id=d["clip_id"],
|
|
102
132
|
schema_version=d["schema_version"],
|
|
@@ -106,7 +136,7 @@ class ClipLoader:
|
|
|
106
136
|
duration_s=d["duration_s"],
|
|
107
137
|
num_keyframes=d["keyframe_count"],
|
|
108
138
|
num_sweeps=d["sweep_count"],
|
|
109
|
-
sensors=
|
|
139
|
+
sensors=sensors,
|
|
110
140
|
)
|
|
111
141
|
|
|
112
142
|
def _load_calibrated_sensors(self) -> list[CalibratedSensor]:
|
|
@@ -228,6 +258,31 @@ class ClipLoader:
|
|
|
228
258
|
"""Clip metadata (from meta.json)."""
|
|
229
259
|
return self._meta
|
|
230
260
|
|
|
261
|
+
@property
|
|
262
|
+
def schema_version(self) -> str:
|
|
263
|
+
"""Shortcut for ``self.meta.schema_version`` — the X-4D format version this clip was written in."""
|
|
264
|
+
return self._meta.schema_version
|
|
265
|
+
|
|
266
|
+
def require_schema_version(self, expected: "str | set[str]") -> None:
|
|
267
|
+
"""Raise ``SchemaVersionMismatch`` if the clip's schema_version isn't in ``expected``.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
expected: a single version string (e.g. ``"0.5"``) or a set of accepted
|
|
271
|
+
versions (e.g. ``{"0.4", "0.5"}``).
|
|
272
|
+
|
|
273
|
+
Example::
|
|
274
|
+
|
|
275
|
+
loader = ClipLoader(path)
|
|
276
|
+
loader.require_schema_version("0.5") # raises if not 0.5
|
|
277
|
+
"""
|
|
278
|
+
actual = self._meta.schema_version
|
|
279
|
+
if isinstance(expected, str):
|
|
280
|
+
if actual != expected:
|
|
281
|
+
raise SchemaVersionMismatch(actual, expected)
|
|
282
|
+
else:
|
|
283
|
+
if actual not in expected:
|
|
284
|
+
raise SchemaVersionMismatch(actual, set(expected))
|
|
285
|
+
|
|
231
286
|
@property
|
|
232
287
|
def samples(self) -> list[Sample]:
|
|
233
288
|
"""All samples, sorted by timestamp."""
|
|
@@ -350,8 +405,12 @@ class ClipLoader:
|
|
|
350
405
|
raise ValueError(f"Invalid frame '{frame}', must be one of {_VALID_FRAMES}")
|
|
351
406
|
|
|
352
407
|
sensor_info = self._meta.sensors[sd.channel]
|
|
353
|
-
|
|
354
|
-
|
|
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
|
|
355
414
|
|
|
356
415
|
path = self._clip_dir / sd.file_path
|
|
357
416
|
raw = np.fromfile(str(path), dtype=np.float32)
|
|
@@ -12,28 +12,119 @@ Coordinate conventions:
|
|
|
12
12
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
|
-
from dataclasses import dataclass
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
16
|
from typing import Any
|
|
17
17
|
|
|
18
18
|
import numpy as np
|
|
19
19
|
|
|
20
20
|
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class PointFormat:
|
|
23
|
+
"""Binary layout of a LiDAR point cloud .bin file.
|
|
24
|
+
|
|
25
|
+
Each point is laid out as ``len(fields)`` consecutive scalars of the
|
|
26
|
+
matching numpy dtype (``types[i]`` for ``fields[i]``). ``bytes_per_point``
|
|
27
|
+
is the on-disk stride in bytes.
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
fields: Per-component field names, e.g. ``["x", "y", "z", "intensity", "time"]``.
|
|
31
|
+
types: Per-component numpy dtype names, e.g. ``["float32", ...]``.
|
|
32
|
+
bytes_per_point: Total bytes per point on disk (== sum of dtype sizes).
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
fields: tuple[str, ...]
|
|
36
|
+
types: tuple[str, ...]
|
|
37
|
+
bytes_per_point: int
|
|
38
|
+
|
|
39
|
+
def __post_init__(self) -> None:
|
|
40
|
+
# Normalise sequences → immutable tuples for free hashability.
|
|
41
|
+
object.__setattr__(self, "fields", tuple(self.fields))
|
|
42
|
+
object.__setattr__(self, "types", tuple(self.types))
|
|
43
|
+
if len(self.fields) != len(self.types):
|
|
44
|
+
raise ValueError(
|
|
45
|
+
f"PointFormat: fields ({len(self.fields)}) and types "
|
|
46
|
+
f"({len(self.types)}) must have equal length"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_dict(cls, d: dict[str, Any]) -> PointFormat:
|
|
51
|
+
return cls(
|
|
52
|
+
fields=tuple(d["fields"]),
|
|
53
|
+
types=tuple(d["types"]),
|
|
54
|
+
bytes_per_point=int(d["bytes_per_point"]),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def to_dict(self) -> dict[str, Any]:
|
|
58
|
+
return {
|
|
59
|
+
"fields": list(self.fields),
|
|
60
|
+
"types": list(self.types),
|
|
61
|
+
"bytes_per_point": self.bytes_per_point,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def num_fields(self) -> int:
|
|
66
|
+
return len(self.fields)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class SensorInfo:
|
|
71
|
+
"""Per-channel sensor metadata embedded in ``meta.json``.
|
|
72
|
+
|
|
73
|
+
Attributes:
|
|
74
|
+
modality: ``"lidar"`` or ``"camera"``.
|
|
75
|
+
point_format: Binary point layout (LiDAR only; ``None`` for cameras).
|
|
76
|
+
resolution: ``(width, height)`` in pixels (cameras only; ``None`` for LiDAR).
|
|
77
|
+
extras: Any additional fields the producer wrote into the sensor dict.
|
|
78
|
+
Preserved verbatim so ``to_dict()`` round-trips unknown keys.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
modality: str
|
|
82
|
+
point_format: PointFormat | None = None
|
|
83
|
+
resolution: tuple[int, int] | None = None
|
|
84
|
+
extras: dict[str, Any] = field(default_factory=dict)
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def from_dict(cls, d: dict[str, Any]) -> SensorInfo:
|
|
88
|
+
modality = d["modality"]
|
|
89
|
+
pf_raw = d.get("point_format")
|
|
90
|
+
point_format = PointFormat.from_dict(pf_raw) if pf_raw is not None else None
|
|
91
|
+
res_raw = d.get("resolution")
|
|
92
|
+
resolution = (int(res_raw[0]), int(res_raw[1])) if res_raw is not None else None
|
|
93
|
+
known = {"modality", "point_format", "resolution"}
|
|
94
|
+
extras = {k: v for k, v in d.items() if k not in known}
|
|
95
|
+
return cls(
|
|
96
|
+
modality=modality,
|
|
97
|
+
point_format=point_format,
|
|
98
|
+
resolution=resolution,
|
|
99
|
+
extras=extras,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def to_dict(self) -> dict[str, Any]:
|
|
103
|
+
out: dict[str, Any] = {"modality": self.modality}
|
|
104
|
+
if self.point_format is not None:
|
|
105
|
+
out["point_format"] = self.point_format.to_dict()
|
|
106
|
+
if self.resolution is not None:
|
|
107
|
+
out["resolution"] = list(self.resolution)
|
|
108
|
+
out.update(self.extras)
|
|
109
|
+
return out
|
|
110
|
+
|
|
111
|
+
|
|
21
112
|
@dataclass(frozen=True)
|
|
22
113
|
class ClipMeta:
|
|
23
114
|
"""Metadata for an X4D clip (from meta.json).
|
|
24
115
|
|
|
25
116
|
Attributes:
|
|
26
117
|
clip_id: Unique clip identifier (e.g. "20180724_n015_scene-0061_000").
|
|
27
|
-
schema_version: X4D format version (e.g. "0.
|
|
118
|
+
schema_version: X4D format version (e.g. "0.5").
|
|
28
119
|
session_id: Recording session identifier.
|
|
29
120
|
vehicle_id: Vehicle identifier.
|
|
30
121
|
capture_time_utc: Capture timestamp in ISO 8601 format.
|
|
31
122
|
duration_s: Clip duration in seconds.
|
|
32
123
|
num_keyframes: Number of keyframes (annotated frames).
|
|
33
124
|
num_sweeps: Number of sweep frames (non-annotated intermediate frames).
|
|
34
|
-
sensors:
|
|
35
|
-
|
|
36
|
-
|
|
125
|
+
sensors: Per-channel sensor metadata, keyed by channel name. Each
|
|
126
|
+
value is a ``SensorInfo`` carrying modality + (for LiDAR) the
|
|
127
|
+
binary ``PointFormat`` describing the .bin file layout.
|
|
37
128
|
"""
|
|
38
129
|
|
|
39
130
|
clip_id: str
|
|
@@ -44,7 +135,7 @@ class ClipMeta:
|
|
|
44
135
|
duration_s: float
|
|
45
136
|
num_keyframes: int
|
|
46
137
|
num_sweeps: int
|
|
47
|
-
sensors: dict[str,
|
|
138
|
+
sensors: dict[str, SensorInfo]
|
|
48
139
|
|
|
49
140
|
|
|
50
141
|
@dataclass(frozen=True)
|
|
@@ -4,6 +4,8 @@ from x4d_devkit.eval.detection import (
|
|
|
4
4
|
DetectionConfig,
|
|
5
5
|
DetectionEval,
|
|
6
6
|
DetectionResult,
|
|
7
|
+
calc_ap,
|
|
8
|
+
calc_tp,
|
|
7
9
|
detzero_detection_config,
|
|
8
10
|
nusc_detection_config,
|
|
9
11
|
)
|
|
@@ -12,6 +14,8 @@ __all__ = [
|
|
|
12
14
|
"DetectionConfig",
|
|
13
15
|
"DetectionEval",
|
|
14
16
|
"DetectionResult",
|
|
17
|
+
"calc_ap",
|
|
18
|
+
"calc_tp",
|
|
15
19
|
"detzero_detection_config",
|
|
16
20
|
"nusc_detection_config",
|
|
17
21
|
]
|
|
@@ -197,7 +197,7 @@ class DetectionResult:
|
|
|
197
197
|
tp_scores: dict[str, float]
|
|
198
198
|
|
|
199
199
|
|
|
200
|
-
def
|
|
200
|
+
def calc_ap(md: MetricData, min_recall: float, min_precision: float) -> float:
|
|
201
201
|
"""Calculate Average Precision from MetricData."""
|
|
202
202
|
prec = np.copy(md.precision)
|
|
203
203
|
prec = prec[round(100 * min_recall) + 1:]
|
|
@@ -208,7 +208,7 @@ def _calc_ap(md: MetricData, min_recall: float, min_precision: float) -> float:
|
|
|
208
208
|
return float(np.mean(prec) / (1.0 - min_precision))
|
|
209
209
|
|
|
210
210
|
|
|
211
|
-
def
|
|
211
|
+
def calc_tp(md: MetricData, min_recall: float, metric_name: str) -> float:
|
|
212
212
|
"""Calculate mean TP metric between min_recall and max_recall."""
|
|
213
213
|
first_ind = round(100 * min_recall) + 1
|
|
214
214
|
last_ind = md.max_recall_ind
|
|
@@ -218,6 +218,11 @@ def _calc_tp(md: MetricData, min_recall: float, metric_name: str) -> float:
|
|
|
218
218
|
return float(np.nanmean(metric_arr[first_ind:last_ind + 1]))
|
|
219
219
|
|
|
220
220
|
|
|
221
|
+
# Deprecated aliases — removed in 0.7.0. Use calc_ap / calc_tp.
|
|
222
|
+
_calc_ap = calc_ap
|
|
223
|
+
_calc_tp = calc_tp
|
|
224
|
+
|
|
225
|
+
|
|
221
226
|
def _extract_boxes(
|
|
222
227
|
clip: ClipLoader,
|
|
223
228
|
config: DetectionConfig,
|
|
@@ -339,7 +344,7 @@ class DetectionEval:
|
|
|
339
344
|
label_aps[class_name] = {}
|
|
340
345
|
for dist_th in cfg.dist_thresholds:
|
|
341
346
|
md = metric_data[class_name][str(dist_th)]
|
|
342
|
-
label_aps[class_name][str(dist_th)] =
|
|
347
|
+
label_aps[class_name][str(dist_th)] = calc_ap(md, cfg.min_recall, cfg.min_precision)
|
|
343
348
|
|
|
344
349
|
mean_dist_aps = {
|
|
345
350
|
cn: float(np.mean(list(label_aps[cn].values())))
|
|
@@ -360,7 +365,7 @@ class DetectionEval:
|
|
|
360
365
|
elif class_name == "barrier" and metric_name in ("attr_err", "vel_err"):
|
|
361
366
|
label_tp_errors[class_name][metric_name] = float("nan")
|
|
362
367
|
else:
|
|
363
|
-
label_tp_errors[class_name][metric_name] =
|
|
368
|
+
label_tp_errors[class_name][metric_name] = calc_tp(md, cfg.min_recall, metric_name)
|
|
364
369
|
|
|
365
370
|
tp_errors = {}
|
|
366
371
|
for metric_name in TP_METRICS:
|
|
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
|