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.
Files changed (47) hide show
  1. {x4d_devkit-0.5.1/x4d_devkit.egg-info → x4d_devkit-0.7.0}/PKG-INFO +1 -1
  2. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/pyproject.toml +1 -1
  3. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/tests/test_coordinate_frames.py +5 -2
  4. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/tests/test_models.py +79 -2
  5. x4d_devkit-0.7.0/tests/test_v06_api_additions.py +162 -0
  6. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/__init__.py +18 -0
  7. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/core/__init__.py +6 -1
  8. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/core/loader.py +63 -4
  9. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/core/models.py +97 -6
  10. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/eval/__init__.py +4 -0
  11. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/eval/detection.py +9 -4
  12. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0/x4d_devkit.egg-info}/PKG-INFO +1 -1
  13. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit.egg-info/SOURCES.txt +1 -0
  14. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/LICENSE +0 -0
  15. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/README.md +0 -0
  16. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/setup.cfg +0 -0
  17. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/tests/test_detection_eval.py +0 -0
  18. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/tests/test_fusion.py +0 -0
  19. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/tests/test_loader.py +0 -0
  20. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/tests/test_matching.py +0 -0
  21. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/tests/test_metrics.py +0 -0
  22. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/tests/test_nuscenes_converter.py +0 -0
  23. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/tests/test_token.py +0 -0
  24. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/tests/test_transform.py +0 -0
  25. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/tests/test_transform_chain.py +0 -0
  26. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/tests/test_validation.py +0 -0
  27. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/cli.py +0 -0
  28. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/client/__init__.py +0 -0
  29. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/client/checkpoints.py +0 -0
  30. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/client/client.py +0 -0
  31. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/client/clips.py +0 -0
  32. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/client/projects.py +0 -0
  33. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/client/test_sets.py +0 -0
  34. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/converters/__init__.py +0 -0
  35. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/converters/nuscenes.py +0 -0
  36. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/core/token.py +0 -0
  37. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/core/transform.py +0 -0
  38. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/eval/matching.py +0 -0
  39. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/eval/metrics.py +0 -0
  40. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/fusion.py +0 -0
  41. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/validation/__init__.py +0 -0
  42. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/validation/report.py +0 -0
  43. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit/validation/validator.py +0 -0
  44. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit.egg-info/dependency_links.txt +0 -0
  45. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit.egg-info/entry_points.txt +0 -0
  46. {x4d_devkit-0.5.1 → x4d_devkit-0.7.0}/x4d_devkit.egg-info/requires.txt +0 -0
  47. {x4d_devkit-0.5.1 → x4d_devkit-0.7.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.5.1
3
+ Version: 0.7.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.5.1"
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": {"fields": ["x", "y", "z", "intensity"]}
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.1",
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={"CAM_FRONT": {"modality": "camera", "resolution": [1600, 900]}},
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__(self, clip_dir: str | Path) -> None:
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=d["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
- pf = sensor_info["point_format"]
354
- num_fields = len(pf["fields"])
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.2").
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: Sensor metadata dict, keyed by channel name. Each value
35
- contains ``modality`` ("lidar"/"camera") and ``point_format``
36
- (for LiDAR channels, describes binary file field layout).
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, dict[str, Any]]
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 _calc_ap(md: MetricData, min_recall: float, min_precision: float) -> float:
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 _calc_tp(md: MetricData, min_recall: float, metric_name: str) -> float:
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)] = _calc_ap(md, cfg.min_recall, cfg.min_precision)
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] = _calc_tp(md, cfg.min_recall, 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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: x4d-devkit
3
- Version: 0.5.1
3
+ Version: 0.7.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
@@ -12,6 +12,7 @@ tests/test_nuscenes_converter.py
12
12
  tests/test_token.py
13
13
  tests/test_transform.py
14
14
  tests/test_transform_chain.py
15
+ tests/test_v06_api_additions.py
15
16
  tests/test_validation.py
16
17
  x4d_devkit/__init__.py
17
18
  x4d_devkit/cli.py
File without changes
File without changes
File without changes
File without changes