x4d-devkit 0.8.0__tar.gz → 0.9.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 (52) hide show
  1. {x4d_devkit-0.8.0/x4d_devkit.egg-info → x4d_devkit-0.9.0}/PKG-INFO +1 -1
  2. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/pyproject.toml +1 -1
  3. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/tests/test_coordinate_frames.py +1 -1
  4. x4d_devkit-0.9.0/tests/test_v06_schema.py +414 -0
  5. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/core/loader.py +101 -19
  6. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/core/models.py +28 -11
  7. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/core/transform.py +35 -0
  8. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/validation/validator.py +51 -0
  9. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0/x4d_devkit.egg-info}/PKG-INFO +1 -1
  10. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit.egg-info/SOURCES.txt +1 -0
  11. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/LICENSE +0 -0
  12. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/README.md +0 -0
  13. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/setup.cfg +0 -0
  14. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/tests/test_box.py +0 -0
  15. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/tests/test_detection_eval.py +0 -0
  16. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/tests/test_evaluate.py +0 -0
  17. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/tests/test_fusion.py +0 -0
  18. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/tests/test_loader.py +0 -0
  19. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/tests/test_matching.py +0 -0
  20. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/tests/test_metrics.py +0 -0
  21. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/tests/test_models.py +0 -0
  22. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/tests/test_nuscenes_converter.py +0 -0
  23. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/tests/test_token.py +0 -0
  24. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/tests/test_transform.py +0 -0
  25. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/tests/test_transform_chain.py +0 -0
  26. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/tests/test_v06_api_additions.py +0 -0
  27. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/tests/test_validation.py +0 -0
  28. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/__init__.py +0 -0
  29. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/cli.py +0 -0
  30. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/client/__init__.py +0 -0
  31. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/client/checkpoints.py +0 -0
  32. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/client/client.py +0 -0
  33. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/client/clips.py +0 -0
  34. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/client/projects.py +0 -0
  35. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/client/test_sets.py +0 -0
  36. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/converters/__init__.py +0 -0
  37. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/converters/nuscenes.py +0 -0
  38. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/core/__init__.py +0 -0
  39. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/core/token.py +0 -0
  40. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/eval/__init__.py +0 -0
  41. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/eval/api.py +0 -0
  42. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/eval/box.py +0 -0
  43. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/eval/detection.py +0 -0
  44. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/eval/matching.py +0 -0
  45. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/eval/metrics.py +0 -0
  46. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/fusion.py +0 -0
  47. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/validation/__init__.py +0 -0
  48. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/validation/report.py +0 -0
  49. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit.egg-info/dependency_links.txt +0 -0
  50. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit.egg-info/entry_points.txt +0 -0
  51. {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit.egg-info/requires.txt +0 -0
  52. {x4d_devkit-0.8.0 → x4d_devkit-0.9.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.8.0
3
+ Version: 0.9.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.8.0"
7
+ version = "0.9.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"
@@ -331,7 +331,7 @@ class TestAnnotationsForSampleFrames:
331
331
  assert not np.allclose(anns_world[0].translation, anns_ego[0].translation, atol=0.1)
332
332
 
333
333
  def test_sensor_frame_raises(self, loader):
334
- with pytest.raises(ValueError, match="sensor frame"):
334
+ with pytest.raises(ValueError, match="ambiguous"):
335
335
  loader.annotations_for_sample("s-0", frame="sensor")
336
336
 
337
337
  def test_invalid_frame_raises(self, loader):
@@ -0,0 +1,414 @@
1
+ """Tests for v0.6 schema additions: annotation_frame_id + per-sensor frame_id.
2
+
3
+ Covers:
4
+ - SensorInfo.frame_id round-trip
5
+ - ClipMeta.annotation_frame_id round-trip
6
+ - ClipLoader.annotation_frame_id property (explicit + back-compat default)
7
+ - ClipLoader.frame_id_for_channel
8
+ - annotations_for_sample(frame=<arbitrary frame_id>)
9
+ - TransformChain.get_ego_from_frame_id
10
+ - validate_clip schema 0.6 strict checks
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import math
17
+ from pathlib import Path
18
+
19
+ import numpy as np
20
+ import pytest
21
+
22
+ from x4d_devkit import ClipLoader, validate_clip
23
+ from x4d_devkit.core.models import ClipMeta, PointFormat, SensorInfo
24
+
25
+
26
+ def _q_from_yaw(yaw: float) -> dict[str, float]:
27
+ return {"qx": 0.0, "qy": 0.0, "qz": math.sin(yaw / 2), "qw": math.cos(yaw / 2)}
28
+
29
+
30
+ def _write_clip(
31
+ clip_dir: Path,
32
+ *,
33
+ schema_version: str,
34
+ annotation_frame_id: str | None,
35
+ sensors_meta: dict,
36
+ calibrated: list[dict],
37
+ samples: list[dict] | None = None,
38
+ sample_data: list[dict] | None = None,
39
+ annotations: list[dict] | None = None,
40
+ ) -> None:
41
+ clip_dir.mkdir(parents=True, exist_ok=True)
42
+ meta = {
43
+ "clip_id": "test-v06",
44
+ "schema_version": schema_version,
45
+ "session_id": "s",
46
+ "vehicle_id": "v",
47
+ "capture_time_utc": "2026-05-06T00:00:00Z",
48
+ "duration_s": 0.0,
49
+ "keyframe_count": len(samples or []),
50
+ "sweep_count": 0,
51
+ "sensors": sensors_meta,
52
+ }
53
+ if annotation_frame_id is not None:
54
+ meta["annotation_frame_id"] = annotation_frame_id
55
+ (clip_dir / "meta.json").write_text(json.dumps(meta))
56
+ (clip_dir / "calibrated_sensor.json").write_text(json.dumps(calibrated))
57
+ (clip_dir / "ego_pose.json").write_text(json.dumps([{
58
+ "ego_pose_token": "ep-0",
59
+ "timestamp_us": 0,
60
+ "translation": {"x": 0, "y": 0, "z": 0},
61
+ "rotation": {"qx": 0, "qy": 0, "qz": 0, "qw": 1},
62
+ }]))
63
+ (clip_dir / "sample.json").write_text(json.dumps(samples or [{
64
+ "sample_token": "s-0", "timestamp_us": 0, "is_keyframe": True,
65
+ "prev": "", "next": "",
66
+ }]))
67
+ (clip_dir / "sample_data.json").write_text(json.dumps(sample_data or [{
68
+ "sample_data_token": "sd-0",
69
+ "sample_token": "s-0",
70
+ "channel": calibrated[0]["channel"],
71
+ "timestamp_us": 0,
72
+ "file_path": "samples/x/0.bin",
73
+ "ego_pose_token": "ep-0",
74
+ "calibrated_sensor_token": calibrated[0]["calibrated_sensor_token"],
75
+ "is_keyframe": True,
76
+ "width": None, "height": None,
77
+ "prev": "", "next": "",
78
+ }]))
79
+ (clip_dir / "annotation.json").write_text(json.dumps(annotations or []))
80
+ (clip_dir / "instance.json").write_text(json.dumps([]))
81
+
82
+
83
+ # --- Model round-trip ---
84
+
85
+
86
+ def test_sensor_info_frame_id_round_trip():
87
+ info = SensorInfo.from_dict({"modality": "lidar", "frame_id": "LIDAR_TOP"})
88
+ assert info.frame_id == "LIDAR_TOP"
89
+ assert info.to_dict()["frame_id"] == "LIDAR_TOP"
90
+
91
+
92
+ def test_sensor_info_no_frame_id_omits_key_in_to_dict():
93
+ info = SensorInfo.from_dict({"modality": "camera"})
94
+ assert info.frame_id is None
95
+ assert "frame_id" not in info.to_dict()
96
+
97
+
98
+ def test_clip_meta_annotation_frame_id_default_none():
99
+ meta = ClipMeta(
100
+ clip_id="x", schema_version="0.5", session_id="s", vehicle_id="v",
101
+ capture_time_utc="t", duration_s=0.0, num_keyframes=0, num_sweeps=0,
102
+ sensors={},
103
+ )
104
+ assert meta.annotation_frame_id is None
105
+
106
+
107
+ # --- Loader: annotation_frame_id property ---
108
+
109
+
110
+ def test_loader_annotation_frame_id_explicit(tmp_path):
111
+ _write_clip(
112
+ tmp_path,
113
+ schema_version="0.6",
114
+ annotation_frame_id="LIDAR_TOP",
115
+ sensors_meta={"LIDAR_TOP": {"modality": "lidar", "frame_id": "LIDAR_TOP",
116
+ "point_format": {"fields": ["x", "y", "z"],
117
+ "types": ["float32"] * 3,
118
+ "bytes_per_point": 12}}},
119
+ calibrated=[{
120
+ "calibrated_sensor_token": "cs-0", "channel": "LIDAR_TOP",
121
+ "translation": {"x": 0, "y": 0, "z": 1.5},
122
+ "rotation": {"qx": 0, "qy": 0, "qz": 0, "qw": 1},
123
+ "intrinsics": None, "distortion": None,
124
+ }],
125
+ )
126
+ loader = ClipLoader(tmp_path)
127
+ assert loader.annotation_frame_id == "LIDAR_TOP"
128
+
129
+
130
+ def test_loader_annotation_frame_id_back_compat_default(tmp_path):
131
+ """Pre-v0.6 clip with no annotation_frame_id falls back to base_link."""
132
+ _write_clip(
133
+ tmp_path,
134
+ schema_version="0.5",
135
+ annotation_frame_id=None,
136
+ sensors_meta={"lidar": {"modality": "lidar"}},
137
+ calibrated=[{
138
+ "calibrated_sensor_token": "cs-0", "channel": "lidar",
139
+ "translation": {"x": 0, "y": 0, "z": 0},
140
+ "rotation": {"qx": 0, "qy": 0, "qz": 0, "qw": 1},
141
+ "intrinsics": None, "distortion": None,
142
+ }],
143
+ )
144
+ loader = ClipLoader(tmp_path)
145
+ assert loader.annotation_frame_id == "base_link"
146
+
147
+
148
+ def test_loader_frame_id_for_channel_explicit(tmp_path):
149
+ _write_clip(
150
+ tmp_path,
151
+ schema_version="0.6",
152
+ annotation_frame_id="base_link",
153
+ sensors_meta={"lidar": {"modality": "lidar", "frame_id": "base_link"}},
154
+ calibrated=[{
155
+ "calibrated_sensor_token": "cs-0", "channel": "lidar",
156
+ "translation": {"x": 0, "y": 0, "z": 0},
157
+ "rotation": {"qx": 0, "qy": 0, "qz": 0, "qw": 1},
158
+ "intrinsics": None, "distortion": None,
159
+ }],
160
+ )
161
+ loader = ClipLoader(tmp_path)
162
+ assert loader.frame_id_for_channel("lidar") == "base_link"
163
+
164
+
165
+ def test_loader_frame_id_for_channel_fallback_to_channel_name(tmp_path):
166
+ """Pre-v0.6 clip falls back to channel ≡ frame_id convention."""
167
+ _write_clip(
168
+ tmp_path,
169
+ schema_version="0.5",
170
+ annotation_frame_id=None,
171
+ sensors_meta={"LIDAR_TOP": {"modality": "lidar"}},
172
+ calibrated=[{
173
+ "calibrated_sensor_token": "cs-0", "channel": "LIDAR_TOP",
174
+ "translation": {"x": 0, "y": 0, "z": 0},
175
+ "rotation": {"qx": 0, "qy": 0, "qz": 0, "qw": 1},
176
+ "intrinsics": None, "distortion": None,
177
+ }],
178
+ )
179
+ loader = ClipLoader(tmp_path)
180
+ assert loader.frame_id_for_channel("LIDAR_TOP") == "LIDAR_TOP"
181
+
182
+
183
+ # --- Loader: annotations_for_sample with arbitrary frame_id ---
184
+
185
+
186
+ def test_annotations_for_sample_in_storage_frame_no_op(tmp_path):
187
+ """Requesting the storage frame returns annotations unchanged."""
188
+ ann = {
189
+ "annotation_token": "a-0", "sample_token": "s-0", "instance_token": "i-0",
190
+ "category": "car",
191
+ "bbox_3d": {
192
+ "translation": {"x": 5.0, "y": 0.0, "z": 0.0},
193
+ "size": {"length": 4.0, "width": 2.0, "height": 1.5},
194
+ "rotation": _q_from_yaw(0.0),
195
+ },
196
+ "num_lidar_pts": 100, "visibility": "4", "velocity": None,
197
+ "attributes": [], "prev": "", "next": "", "score": None,
198
+ }
199
+ _write_clip(
200
+ tmp_path,
201
+ schema_version="0.6",
202
+ annotation_frame_id="LIDAR_TOP",
203
+ sensors_meta={"LIDAR_TOP": {"modality": "lidar", "frame_id": "LIDAR_TOP",
204
+ "point_format": {"fields": ["x", "y", "z"],
205
+ "types": ["float32"] * 3,
206
+ "bytes_per_point": 12}}},
207
+ calibrated=[{
208
+ "calibrated_sensor_token": "cs-0", "channel": "LIDAR_TOP",
209
+ "translation": {"x": 0, "y": 0, "z": 1.5},
210
+ "rotation": {"qx": 0, "qy": 0, "qz": 0, "qw": 1},
211
+ "intrinsics": None, "distortion": None,
212
+ }],
213
+ annotations=[ann],
214
+ )
215
+ loader = ClipLoader(tmp_path)
216
+ [a] = loader.annotations_for_sample("s-0", frame="LIDAR_TOP")
217
+ np.testing.assert_allclose(a.translation, [5.0, 0.0, 0.0])
218
+
219
+
220
+ def test_annotations_for_sample_lidar_top_to_base_link(tmp_path):
221
+ """Annotation in LIDAR_TOP with z-offset 1.5 → base_link adds 1.5 to z."""
222
+ ann = {
223
+ "annotation_token": "a-0", "sample_token": "s-0", "instance_token": "i-0",
224
+ "category": "car",
225
+ "bbox_3d": {
226
+ "translation": {"x": 5.0, "y": 0.0, "z": 0.0},
227
+ "size": {"length": 4.0, "width": 2.0, "height": 1.5},
228
+ "rotation": _q_from_yaw(0.0),
229
+ },
230
+ "num_lidar_pts": 100, "visibility": "4", "velocity": None,
231
+ "attributes": [], "prev": "", "next": "", "score": None,
232
+ }
233
+ _write_clip(
234
+ tmp_path,
235
+ schema_version="0.6",
236
+ annotation_frame_id="LIDAR_TOP",
237
+ sensors_meta={"LIDAR_TOP": {"modality": "lidar", "frame_id": "LIDAR_TOP",
238
+ "point_format": {"fields": ["x", "y", "z"],
239
+ "types": ["float32"] * 3,
240
+ "bytes_per_point": 12}}},
241
+ calibrated=[{
242
+ "calibrated_sensor_token": "cs-0", "channel": "LIDAR_TOP",
243
+ "translation": {"x": 0, "y": 0, "z": 1.5},
244
+ "rotation": {"qx": 0, "qy": 0, "qz": 0, "qw": 1},
245
+ "intrinsics": None, "distortion": None,
246
+ }],
247
+ annotations=[ann],
248
+ )
249
+ loader = ClipLoader(tmp_path)
250
+ [a] = loader.annotations_for_sample("s-0", frame="base_link")
251
+ np.testing.assert_allclose(a.translation, [5.0, 0.0, 1.5])
252
+
253
+
254
+ def test_annotations_for_sample_unknown_frame_id_raises(tmp_path):
255
+ _write_clip(
256
+ tmp_path,
257
+ schema_version="0.6",
258
+ annotation_frame_id="base_link",
259
+ sensors_meta={"lidar": {"modality": "lidar", "frame_id": "base_link"}},
260
+ calibrated=[{
261
+ "calibrated_sensor_token": "cs-0", "channel": "lidar",
262
+ "translation": {"x": 0, "y": 0, "z": 0},
263
+ "rotation": {"qx": 0, "qy": 0, "qz": 0, "qw": 1},
264
+ "intrinsics": None, "distortion": None,
265
+ }],
266
+ annotations=[{
267
+ "annotation_token": "a-0", "sample_token": "s-0", "instance_token": "i-0",
268
+ "category": "car",
269
+ "bbox_3d": {"translation": {"x": 0, "y": 0, "z": 0},
270
+ "size": {"length": 1, "width": 1, "height": 1},
271
+ "rotation": _q_from_yaw(0.0)},
272
+ "num_lidar_pts": 0, "visibility": "", "velocity": None,
273
+ "attributes": [], "prev": "", "next": "", "score": None,
274
+ }],
275
+ )
276
+ loader = ClipLoader(tmp_path)
277
+ with pytest.raises(ValueError, match="Invalid frame"):
278
+ loader.annotations_for_sample("s-0", frame="never_exists_frame")
279
+
280
+
281
+ # --- TransformChain.get_ego_from_frame_id ---
282
+
283
+
284
+ def test_transform_chain_ego_from_frame_id_base_link_is_identity(tmp_path):
285
+ _write_clip(
286
+ tmp_path,
287
+ schema_version="0.6",
288
+ annotation_frame_id="base_link",
289
+ sensors_meta={"lidar": {"modality": "lidar", "frame_id": "base_link"}},
290
+ calibrated=[{
291
+ "calibrated_sensor_token": "cs-0", "channel": "lidar",
292
+ "translation": {"x": 0, "y": 0, "z": 0},
293
+ "rotation": {"qx": 0, "qy": 0, "qz": 0, "qw": 1},
294
+ "intrinsics": None, "distortion": None,
295
+ }],
296
+ )
297
+ loader = ClipLoader(tmp_path)
298
+ from x4d_devkit.core.transform import TransformChain
299
+ chain = TransformChain(loader)
300
+ T = chain.get_ego_from_frame_id("base_link")
301
+ np.testing.assert_allclose(T.translation, [0, 0, 0])
302
+ np.testing.assert_allclose(T.rotation, np.eye(3))
303
+
304
+
305
+ def test_transform_chain_ego_from_frame_id_resolves_via_sensor_frame_id(tmp_path):
306
+ """When a sensor declares frame_id != channel, lookup uses frame_id."""
307
+ _write_clip(
308
+ tmp_path,
309
+ schema_version="0.6",
310
+ annotation_frame_id="base_link",
311
+ sensors_meta={"recon_pcd": {"modality": "lidar", "frame_id": "camera-front"}},
312
+ calibrated=[{
313
+ "calibrated_sensor_token": "cs-0", "channel": "recon_pcd",
314
+ "translation": {"x": 1.0, "y": 0, "z": 0},
315
+ "rotation": {"qx": 0, "qy": 0, "qz": 0, "qw": 1},
316
+ "intrinsics": None, "distortion": None,
317
+ }],
318
+ )
319
+ loader = ClipLoader(tmp_path)
320
+ from x4d_devkit.core.transform import TransformChain
321
+ chain = TransformChain(loader)
322
+ T = chain.get_ego_from_frame_id("camera-front")
323
+ np.testing.assert_allclose(T.translation, [1.0, 0, 0])
324
+
325
+
326
+ # --- Validator: schema 0.6 strict checks ---
327
+
328
+
329
+ def test_validate_v06_missing_annotation_frame_id_errors(tmp_path):
330
+ _write_clip(
331
+ tmp_path,
332
+ schema_version="0.6",
333
+ annotation_frame_id=None, # missing on purpose
334
+ sensors_meta={"lidar": {"modality": "lidar", "frame_id": "base_link"}},
335
+ calibrated=[{
336
+ "calibrated_sensor_token": "cs-0", "channel": "lidar",
337
+ "translation": {"x": 0, "y": 0, "z": 0},
338
+ "rotation": {"qx": 0, "qy": 0, "qz": 0, "qw": 1},
339
+ "intrinsics": None, "distortion": None,
340
+ }],
341
+ )
342
+ report = validate_clip(tmp_path)
343
+ assert any(i.check == "annotation_frame_id" for i in report.errors)
344
+
345
+
346
+ def test_validate_v06_missing_sensor_frame_id_errors(tmp_path):
347
+ _write_clip(
348
+ tmp_path,
349
+ schema_version="0.6",
350
+ annotation_frame_id="base_link",
351
+ sensors_meta={"lidar": {"modality": "lidar"}}, # no frame_id
352
+ calibrated=[{
353
+ "calibrated_sensor_token": "cs-0", "channel": "lidar",
354
+ "translation": {"x": 0, "y": 0, "z": 0},
355
+ "rotation": {"qx": 0, "qy": 0, "qz": 0, "qw": 1},
356
+ "intrinsics": None, "distortion": None,
357
+ }],
358
+ )
359
+ report = validate_clip(tmp_path)
360
+ assert any(i.check == "sensor_frame_id" for i in report.errors)
361
+
362
+
363
+ def test_validate_v06_unresolvable_annotation_frame_errors(tmp_path):
364
+ _write_clip(
365
+ tmp_path,
366
+ schema_version="0.6",
367
+ annotation_frame_id="non_existent_frame",
368
+ sensors_meta={"lidar": {"modality": "lidar", "frame_id": "base_link"}},
369
+ calibrated=[{
370
+ "calibrated_sensor_token": "cs-0", "channel": "lidar",
371
+ "translation": {"x": 0, "y": 0, "z": 0},
372
+ "rotation": {"qx": 0, "qy": 0, "qz": 0, "qw": 1},
373
+ "intrinsics": None, "distortion": None,
374
+ }],
375
+ )
376
+ report = validate_clip(tmp_path)
377
+ msgs = [i.message for i in report.errors if i.check == "annotation_frame_id"]
378
+ assert any("does not resolve" in m for m in msgs)
379
+
380
+
381
+ def test_validate_v06_passes_with_lidar_top_annotation_frame(tmp_path):
382
+ """Scenario 1 (nuScenes-style): annotation_frame_id == sensor's own frame_id."""
383
+ ann = {
384
+ "annotation_token": "a-0", "sample_token": "s-0", "instance_token": "i-0",
385
+ "category": "car",
386
+ "bbox_3d": {"translation": {"x": 5, "y": 0, "z": 0},
387
+ "size": {"length": 4, "width": 2, "height": 1.5},
388
+ "rotation": _q_from_yaw(0.0)},
389
+ "num_lidar_pts": 50, "visibility": "4", "velocity": None,
390
+ "attributes": [], "prev": "", "next": "", "score": None,
391
+ }
392
+ _write_clip(
393
+ tmp_path,
394
+ schema_version="0.6",
395
+ annotation_frame_id="LIDAR_TOP",
396
+ sensors_meta={"LIDAR_TOP": {"modality": "lidar", "frame_id": "LIDAR_TOP",
397
+ "point_format": {"fields": ["x", "y", "z", "intensity", "t"],
398
+ "types": ["float32"] * 5,
399
+ "bytes_per_point": 20}}},
400
+ calibrated=[{
401
+ "calibrated_sensor_token": "cs-0", "channel": "LIDAR_TOP",
402
+ "translation": {"x": 0, "y": 0, "z": 1.5},
403
+ "rotation": {"qx": 0, "qy": 0, "qz": 0, "qw": 1},
404
+ "intrinsics": None, "distortion": None,
405
+ }],
406
+ annotations=[ann],
407
+ )
408
+ pcd_path = tmp_path / "samples/x/0.bin"
409
+ pcd_path.parent.mkdir(parents=True, exist_ok=True)
410
+ np.zeros(5, dtype=np.float32).tofile(pcd_path)
411
+
412
+ report = validate_clip(tmp_path)
413
+ schema_errors = [i for i in report.errors if i.check in ("annotation_frame_id", "sensor_frame_id")]
414
+ assert schema_errors == [], f"unexpected schema errors: {schema_errors}"
@@ -23,6 +23,12 @@ from x4d_devkit.core.transform import Transform, TransformChain, rotation_matrix
23
23
 
24
24
  FrameType = Literal["sensor", "ego", "world"]
25
25
  _VALID_FRAMES = {"sensor", "ego", "world"}
26
+ # In annotation/transform APIs the user may pass the literal aliases above
27
+ # OR any frame_id string that resolves in the calibration tree. We resolve
28
+ # "ego" and "base_link" to the ego (vehicle body) frame; "world" composes
29
+ # with ego_pose; any other string is treated as a sensor frame_id and looked
30
+ # up via the calibration tree.
31
+ _EGO_ALIASES = {"ego", "base_link"}
26
32
 
27
33
 
28
34
  class SchemaVersionMismatch(ValueError):
@@ -58,14 +64,24 @@ class ClipLoader:
58
64
  loader = ClipLoader("/data/clips/my_clip")
59
65
  print(loader.meta.clip_id)
60
66
 
61
- # Iterate samples and load point clouds
67
+ # Find lidar channels by modality — never hardcode "lidar" / "LIDAR_TOP".
68
+ # The directory/channel name is producer-defined: nuScenes-converted clips
69
+ # use "LIDAR_TOP", X-4D platform fused output uses "lidar", reconstruction
70
+ # pipelines may use any name. Always look up via meta.sensors.
71
+ lidar_channels = [
72
+ ch for ch, info in loader.meta.sensors.items()
73
+ if info.modality == "lidar"
74
+ ]
75
+
62
76
  for sample in loader.samples:
63
77
  for sd in loader.sample_data_for_sample(sample.token):
64
- if "LIDAR" in sd.channel:
78
+ if sd.channel in lidar_channels:
65
79
  pts = loader.load_point_cloud(sd, frame="ego")
66
80
 
67
- # Get annotations in ego frame for training
68
- anns = loader.annotations_for_sample(sample.token, frame="ego")
81
+ # Get annotations in their stored frame (loader.annotation_frame_id)
82
+ anns = loader.annotations_for_sample(sample.token)
83
+ # Or transform to a specific frame_id from the calibration tree
84
+ anns_in_top_lidar = loader.annotations_for_sample(sample.token, frame="LIDAR_TOP")
69
85
  """
70
86
 
71
87
  def __init__(
@@ -137,6 +153,7 @@ class ClipLoader:
137
153
  num_keyframes=d["keyframe_count"],
138
154
  num_sweeps=d["sweep_count"],
139
155
  sensors=sensors,
156
+ annotation_frame_id=d.get("annotation_frame_id"),
140
157
  )
141
158
 
142
159
  def _load_calibrated_sensors(self) -> list[CalibratedSensor]:
@@ -263,6 +280,31 @@ class ClipLoader:
263
280
  """Shortcut for ``self.meta.schema_version`` — the X-4D format version this clip was written in."""
264
281
  return self._meta.schema_version
265
282
 
283
+ @property
284
+ def annotation_frame_id(self) -> str:
285
+ """The vehicle-local frame_id annotations are stored in.
286
+
287
+ Returns ``meta.annotation_frame_id`` if explicitly set (v0.6+ clips).
288
+ Pre-v0.6 clips fall back to ``"base_link"`` — the per-sample ego frame
289
+ all earlier loader code already assumed (``annotations_for_sample(frame="ego")``
290
+ was a no-op transform).
291
+ """
292
+ if self._meta.annotation_frame_id is not None:
293
+ return self._meta.annotation_frame_id
294
+ return "base_link"
295
+
296
+ def frame_id_for_channel(self, channel: str) -> str:
297
+ """Resolve the ROS frame_id of a sensor channel.
298
+
299
+ Looks up ``meta.sensors[channel].frame_id`` (v0.6+). Falls back to the
300
+ channel name itself for older clips, per the v0.4 ``channel ≡ frame_id``
301
+ convention.
302
+ """
303
+ info = self._meta.sensors.get(channel)
304
+ if info is not None and info.frame_id is not None:
305
+ return info.frame_id
306
+ return channel
307
+
266
308
  def require_schema_version(self, expected: "str | set[str]") -> None:
267
309
  """Raise ``SchemaVersionMismatch`` if the clip's schema_version isn't in ``expected``.
268
310
 
@@ -355,34 +397,74 @@ class ClipLoader:
355
397
  def annotations_for_sample(
356
398
  self,
357
399
  sample_token: str,
358
- frame: FrameType = "ego",
400
+ frame: str = "ego",
359
401
  ) -> list[Annotation]:
360
- """All annotations for a given sample.
402
+ """All annotations for a given sample, transformed to the requested frame.
361
403
 
362
404
  Args:
363
405
  sample_token: The sample token.
364
- frame: Target coordinate frame.
365
- - "ego" (default, v0.5 native): no transform — annotations are
366
- stored in the per-sample ego frame.
367
- - "world": transform from ego to world using the ego_pose of
368
- the LiDAR sample_data for this sample (or first sample_data
369
- if no LiDAR).
406
+ frame: Target coordinate frame. Accepted values:
407
+ - ``"ego"`` / ``"base_link"`` (default): vehicle body frame.
408
+ - ``"world"``: clip-local world frame (composes with ego_pose).
409
+ - any sensor's ROS frame_id present in the calibration tree
410
+ (e.g. ``"LIDAR_TOP"``, ``"camera-front"``).
411
+ - ``"sensor"`` is rejected (ambiguous — no single sensor implied).
370
412
 
371
413
  Returns:
372
414
  List of Annotation objects in the requested frame.
373
415
  """
374
- if frame not in _VALID_FRAMES:
375
- raise ValueError(f"Invalid frame '{frame}', must be one of {_VALID_FRAMES}")
376
416
  if frame == "sensor":
377
- raise ValueError("Annotations cannot be transformed to sensor frame")
417
+ raise ValueError(
418
+ "frame='sensor' is ambiguous for annotations; pass an explicit "
419
+ "frame_id (e.g. 'LIDAR_TOP') or use 'ego' / 'base_link' / 'world'."
420
+ )
378
421
 
379
422
  anns = self._ann_by_sample.get(sample_token, [])
380
- if frame == "ego" or not anns:
423
+ if not anns:
424
+ return anns
425
+
426
+ T = self._transform_for_annotation_target(sample_token, frame)
427
+ if T is None:
381
428
  return anns
429
+ return [self._transform_annotation(a, T) for a in anns]
430
+
431
+ def _transform_for_annotation_target(
432
+ self, sample_token: str, target_frame: str
433
+ ) -> Transform | None:
434
+ """Build the transform from the clip's annotation frame to ``target_frame``.
382
435
 
383
- # frame == "world": transform per-sample-ego world via ego_pose.
384
- world_from_ego = self._get_world_from_ego_for_sample(sample_token)
385
- return [self._transform_annotation(a, world_from_ego) for a in anns]
436
+ Returns ``None`` when the requested frame matches the storage frame
437
+ (no transform needed).
438
+ """
439
+ ann_frame = self.annotation_frame_id # e.g. "base_link" or "LIDAR_TOP"
440
+ if target_frame == ann_frame:
441
+ return None
442
+ if target_frame in _EGO_ALIASES and ann_frame in _EGO_ALIASES:
443
+ return None
444
+
445
+ chain = self._get_transform_chain()
446
+ # T(ego ← annotation_frame): identity if annotation frame is ego/base_link;
447
+ # else look up the sensor whose frame_id == ann_frame.
448
+ if ann_frame in _EGO_ALIASES:
449
+ ego_from_ann = Transform.identity()
450
+ else:
451
+ ego_from_ann = chain.get_ego_from_frame_id(ann_frame)
452
+
453
+ # T(target ← ego)
454
+ if target_frame in _EGO_ALIASES:
455
+ target_from_ego = Transform.identity()
456
+ elif target_frame == "world":
457
+ target_from_ego = self._get_world_from_ego_for_sample(sample_token)
458
+ else:
459
+ try:
460
+ ego_from_target = chain.get_ego_from_frame_id(target_frame)
461
+ except KeyError as e:
462
+ raise ValueError(
463
+ f"Invalid frame {target_frame!r} for annotations: {e}"
464
+ ) from None
465
+ target_from_ego = ego_from_target.inverse
466
+
467
+ return target_from_ego @ ego_from_ann
386
468
 
387
469
  def load_point_cloud(
388
470
  self,
@@ -72,6 +72,9 @@ class SensorInfo:
72
72
 
73
73
  Attributes:
74
74
  modality: ``"lidar"`` or ``"camera"``.
75
+ frame_id: ROS-style frame_id of this channel's stored data. Required by
76
+ v0.6 schema; ``None`` on older clips. For fused outputs the producer
77
+ should set this to the fusion target frame (e.g. ``"base_link"``).
75
78
  point_format: Binary point layout (LiDAR only; ``None`` for cameras).
76
79
  resolution: ``(width, height)`` in pixels (cameras only; ``None`` for LiDAR).
77
80
  extras: Any additional fields the producer wrote into the sensor dict.
@@ -79,6 +82,7 @@ class SensorInfo:
79
82
  """
80
83
 
81
84
  modality: str
85
+ frame_id: str | None = None
82
86
  point_format: PointFormat | None = None
83
87
  resolution: tuple[int, int] | None = None
84
88
  extras: dict[str, Any] = field(default_factory=dict)
@@ -86,14 +90,16 @@ class SensorInfo:
86
90
  @classmethod
87
91
  def from_dict(cls, d: dict[str, Any]) -> SensorInfo:
88
92
  modality = d["modality"]
93
+ frame_id = d.get("frame_id")
89
94
  pf_raw = d.get("point_format")
90
95
  point_format = PointFormat.from_dict(pf_raw) if pf_raw is not None else None
91
96
  res_raw = d.get("resolution")
92
97
  resolution = (int(res_raw[0]), int(res_raw[1])) if res_raw is not None else None
93
- known = {"modality", "point_format", "resolution"}
98
+ known = {"modality", "frame_id", "point_format", "resolution"}
94
99
  extras = {k: v for k, v in d.items() if k not in known}
95
100
  return cls(
96
101
  modality=modality,
102
+ frame_id=frame_id,
97
103
  point_format=point_format,
98
104
  resolution=resolution,
99
105
  extras=extras,
@@ -101,6 +107,8 @@ class SensorInfo:
101
107
 
102
108
  def to_dict(self) -> dict[str, Any]:
103
109
  out: dict[str, Any] = {"modality": self.modality}
110
+ if self.frame_id is not None:
111
+ out["frame_id"] = self.frame_id
104
112
  if self.point_format is not None:
105
113
  out["point_format"] = self.point_format.to_dict()
106
114
  if self.resolution is not None:
@@ -125,6 +133,10 @@ class ClipMeta:
125
133
  sensors: Per-channel sensor metadata, keyed by channel name. Each
126
134
  value is a ``SensorInfo`` carrying modality + (for LiDAR) the
127
135
  binary ``PointFormat`` describing the .bin file layout.
136
+ annotation_frame_id: The ROS frame_id annotations are stored in.
137
+ Required by v0.6 schema. ``None`` on older clips (callers can
138
+ consult ``ClipLoader.annotation_frame_id`` for a back-compat
139
+ fallback that infers ``"base_link"`` for v0.5 clips).
128
140
  """
129
141
 
130
142
  clip_id: str
@@ -136,6 +148,7 @@ class ClipMeta:
136
148
  num_keyframes: int
137
149
  num_sweeps: int
138
150
  sensors: dict[str, SensorInfo]
151
+ annotation_frame_id: str | None = None
139
152
 
140
153
 
141
154
  @dataclass(frozen=True)
@@ -245,27 +258,31 @@ class SampleData:
245
258
 
246
259
  @dataclass(frozen=True)
247
260
  class Annotation:
248
- """A 3D bounding box annotation in **per-sample ego frame** (v0.5).
261
+ """A 3D bounding box annotation in the clip's vehicle-local annotation frame.
249
262
 
250
- Annotations are natively stored in the ego frame of the sample they belong
251
- to (the ego_pose of the sample's LiDAR keyframe). Use
252
- ``ClipLoader.annotations_for_sample(..., frame="world")`` to transform to
253
- the clip's local world frame when needed (rare typically only for
254
- cross-frame trajectory visualization).
263
+ Annotations are stored in whichever vehicle-local frame the producer
264
+ declared via ``meta.annotation_frame_id`` (v0.6+) typically ``"base_link"``
265
+ for fused-lidar clips, ``"LIDAR_TOP"`` for nuScenes-style top-lidar clips,
266
+ or another sensor's frame_id. The frame is rigid relative to ``base_link``
267
+ via calibration, so per-sample timing is determined by ``sample_token``.
268
+ For v0.5 clips without an explicit field, the convention is per-sample ego
269
+ frame (== ``base_link``). Use ``ClipLoader.annotations_for_sample(..., frame=...)``
270
+ to transform to ``"world"`` or any other frame_id in the calibration tree.
255
271
 
256
272
  Attributes:
257
273
  token: Unique annotation token.
258
274
  sample_token: Token of the sample this annotation belongs to.
259
275
  instance_token: Token of the tracked object instance.
260
276
  category: Object category string (e.g. "car", "pedestrian").
261
- translation: (3,) float64 array — box center [x, y, z] in this sample's ego frame.
277
+ translation: (3,) float64 array — box center [x, y, z] in the
278
+ annotation frame at this sample's timestamp.
262
279
  size: (3,) float64 array — box dimensions [length, width, height] in meters.
263
280
  rotation: (4,) float64 array — box orientation as quaternion [qx, qy, qz, qw],
264
- relative to this sample's ego frame.
281
+ relative to the annotation frame.
265
282
  num_lidar_pts: Number of LiDAR points inside this box (real-counted, ≥ 0).
266
283
  visibility: Visibility level as string ("1" to "4", or "" if unknown).
267
- velocity: (3,) float64 array — velocity [vx, vy, vz] in m/s, **ego frame**
268
- (forward = +x, left = +y), or None.
284
+ velocity: (3,) float64 array — velocity [vx, vy, vz] in m/s, in the
285
+ annotation frame (forward = +x, left = +y), or None.
269
286
  attributes: List of attribute strings (e.g. ["vehicle.moving"]).
270
287
  prev: Token of previous annotation of same instance, or "" if first.
271
288
  next: Token of next annotation of same instance, or "" if last.
@@ -191,6 +191,14 @@ class TransformChain:
191
191
  def __init__(self, loader: ClipLoader) -> None:
192
192
  self._loader = loader
193
193
  self._cs_by_channel = {cs.channel: cs for cs in loader.calibrated_sensors}
194
+ # Map ROS frame_id → channel: prefer explicit meta.sensors[ch].frame_id
195
+ # (v0.6+); fall back to channel-name equality for older clips.
196
+ self._channel_by_frame_id: dict[str, str] = {}
197
+ for ch, info in loader.meta.sensors.items():
198
+ fid = info.frame_id if info.frame_id is not None else ch
199
+ self._channel_by_frame_id[fid] = ch
200
+ for ch in self._cs_by_channel:
201
+ self._channel_by_frame_id.setdefault(ch, ch)
194
202
 
195
203
  def get_ego_from_sensor(self, channel: str) -> Transform:
196
204
  """Get the static sensor-to-ego transform for a channel.
@@ -206,6 +214,33 @@ class TransformChain:
206
214
  cs = self._cs_by_channel[channel]
207
215
  return Transform.from_quat(cs.translation, cs.rotation)
208
216
 
217
+ def get_ego_from_frame_id(self, frame_id: str) -> Transform:
218
+ """Get the static frame_id-to-ego transform.
219
+
220
+ Resolves ``frame_id`` to the calibrated channel that declares it
221
+ (via ``meta.sensors[ch].frame_id``, falling back to channel-name
222
+ equality for older clips). For ``frame_id == "base_link"`` this is
223
+ the identity transform.
224
+
225
+ Args:
226
+ frame_id: ROS frame_id (e.g. ``"LIDAR_TOP"``, ``"camera-front"``).
227
+
228
+ Returns:
229
+ Transform from the named frame to the ego (base_link) frame.
230
+
231
+ Raises:
232
+ KeyError: if no sensor declares ``frame_id``.
233
+ """
234
+ if frame_id == "base_link":
235
+ return Transform.identity()
236
+ ch = self._channel_by_frame_id.get(frame_id)
237
+ if ch is None:
238
+ raise KeyError(
239
+ f"frame_id {frame_id!r} not found in calibration tree "
240
+ f"(known: {sorted(self._channel_by_frame_id)})"
241
+ )
242
+ return self.get_ego_from_sensor(ch)
243
+
209
244
  def get_world_from_ego(self, ego_pose_token: str) -> Transform:
210
245
  """Get the ego-to-world transform for a specific ego pose.
211
246
 
@@ -101,6 +101,57 @@ def validate_clip(clip_dir: str | Path) -> ValidationReport:
101
101
  if "meta.json" in jsons:
102
102
  clip_id = jsons["meta.json"].get("clip_id", clip_id)
103
103
 
104
+ # Schema v0.6: explicit annotation_frame_id + per-sensor frame_id, both
105
+ # resolving against calibrated_sensor.json.
106
+ if "meta.json" in jsons:
107
+ meta = jsons["meta.json"]
108
+ sv = str(meta.get("schema_version", ""))
109
+ if sv == "0.6":
110
+ cs_records = jsons.get("calibrated_sensor.json", []) or []
111
+ cs_channels = {r.get("channel") for r in cs_records if r.get("channel")}
112
+ calib_frame_ids = set(cs_channels) | {"base_link"}
113
+ sensors = (meta.get("sensors") or {})
114
+ sensor_frame_ids: set[str] = set()
115
+ for ch, info in sensors.items():
116
+ fid = (info or {}).get("frame_id") if isinstance(info, dict) else None
117
+ if not fid:
118
+ issues.append(
119
+ ValidationIssue(
120
+ "sensor_frame_id",
121
+ "error",
122
+ f"sensors[{ch!r}].frame_id is required in schema 0.6",
123
+ )
124
+ )
125
+ continue
126
+ sensor_frame_ids.add(fid)
127
+ if ch not in cs_channels:
128
+ issues.append(
129
+ ValidationIssue(
130
+ "sensor_frame_id",
131
+ "error",
132
+ f"sensors[{ch!r}] has no matching calibrated_sensor record",
133
+ )
134
+ )
135
+ calib_frame_ids |= sensor_frame_ids
136
+ ann_fid = meta.get("annotation_frame_id")
137
+ if not ann_fid:
138
+ issues.append(
139
+ ValidationIssue(
140
+ "annotation_frame_id",
141
+ "error",
142
+ "meta.annotation_frame_id is required in schema 0.6",
143
+ )
144
+ )
145
+ elif ann_fid not in calib_frame_ids:
146
+ issues.append(
147
+ ValidationIssue(
148
+ "annotation_frame_id",
149
+ "error",
150
+ f"annotation_frame_id={ann_fid!r} does not resolve in calibration tree "
151
+ f"(known: {sorted(calib_frame_ids)})",
152
+ )
153
+ )
154
+
104
155
  # If we can't parse essential files, return early
105
156
  if "sample.json" not in jsons or "sample_data.json" not in jsons:
106
157
  return ValidationReport(clip_id=clip_id, issues=issues)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: x4d-devkit
3
- Version: 0.8.0
3
+ Version: 0.9.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
@@ -15,6 +15,7 @@ tests/test_token.py
15
15
  tests/test_transform.py
16
16
  tests/test_transform_chain.py
17
17
  tests/test_v06_api_additions.py
18
+ tests/test_v06_schema.py
18
19
  tests/test_validation.py
19
20
  x4d_devkit/__init__.py
20
21
  x4d_devkit/cli.py
File without changes
File without changes
File without changes
File without changes
File without changes