x4d-devkit 0.11.1__tar.gz → 0.12.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.11.1/x4d_devkit.egg-info → x4d_devkit-0.12.0}/PKG-INFO +18 -9
  2. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/README.md +17 -8
  3. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/pyproject.toml +1 -1
  4. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/tests/test_coordinate_frames.py +174 -131
  5. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/tests/test_v06_schema.py +1 -1
  6. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/core/loader.py +133 -121
  7. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/core/models.py +1 -1
  8. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0/x4d_devkit.egg-info}/PKG-INFO +18 -9
  9. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/LICENSE +0 -0
  10. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/setup.cfg +0 -0
  11. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/tests/test_box.py +0 -0
  12. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/tests/test_detection_eval.py +0 -0
  13. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/tests/test_evaluate.py +0 -0
  14. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/tests/test_fusion.py +0 -0
  15. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/tests/test_loader.py +0 -0
  16. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/tests/test_matching.py +0 -0
  17. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/tests/test_metrics.py +0 -0
  18. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/tests/test_models.py +0 -0
  19. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/tests/test_nuscenes_converter.py +0 -0
  20. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/tests/test_token.py +0 -0
  21. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/tests/test_transform.py +0 -0
  22. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/tests/test_transform_chain.py +0 -0
  23. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/tests/test_v06_api_additions.py +0 -0
  24. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/tests/test_validation.py +0 -0
  25. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/__init__.py +0 -0
  26. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/cli.py +0 -0
  27. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/client/__init__.py +0 -0
  28. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/client/checkpoints.py +0 -0
  29. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/client/client.py +0 -0
  30. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/client/clips.py +0 -0
  31. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/client/projects.py +0 -0
  32. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/client/test_sets.py +0 -0
  33. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/converters/__init__.py +0 -0
  34. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/converters/nuscenes.py +0 -0
  35. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/core/__init__.py +0 -0
  36. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/core/token.py +0 -0
  37. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/core/transform.py +0 -0
  38. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/eval/__init__.py +0 -0
  39. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/eval/api.py +0 -0
  40. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/eval/box.py +0 -0
  41. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/eval/detection.py +0 -0
  42. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/eval/matching.py +0 -0
  43. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/eval/metrics.py +0 -0
  44. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/fusion.py +0 -0
  45. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/validation/__init__.py +0 -0
  46. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/validation/report.py +0 -0
  47. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/validation/validator.py +0 -0
  48. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit.egg-info/SOURCES.txt +0 -0
  49. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit.egg-info/dependency_links.txt +0 -0
  50. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit.egg-info/entry_points.txt +0 -0
  51. {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit.egg-info/requires.txt +0 -0
  52. {x4d_devkit-0.11.1 → x4d_devkit-0.12.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.11.1
3
+ Version: 0.12.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
@@ -65,22 +65,31 @@ for sample in loader.samples:
65
65
 
66
66
  ### Coordinate frame transforms
67
67
 
68
- Point clouds and annotations can be loaded in different coordinate frames:
68
+ Frames are real frame_id strings: any node in the calibration tree (e.g.
69
+ `"LIDAR_TOP"`, `"base_link"`, `"cam_front"`) plus the constant
70
+ `"clip_world"` (the SLAM-anchored clip-local world). The legacy aliases
71
+ `"sensor"`, `"ego"`, `"world"` are not accepted.
69
72
 
70
73
  ```python
74
+ from x4d_devkit import ClipLoader
75
+ from x4d_devkit.core.loader import CLIP_WORLD_FRAME_ID
76
+
71
77
  loader = ClipLoader("/path/to/clip")
72
78
  sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
73
79
 
74
80
  # Load point cloud in different frames
75
- pts_sensor = loader.load_point_cloud(sd, frame="sensor") # raw (default)
76
- pts_ego = loader.load_point_cloud(sd, frame="ego") # sensor → ego
77
- pts_world = loader.load_point_cloud(sd, frame="world") # sensor → world
81
+ pts_sensor = loader.load_point_cloud(sd) # raw sensor (default)
82
+ pts_ego = loader.load_point_cloud(sd, frame=loader.ego_pose_frame_id) # sensor → ego
83
+ pts_world = loader.load_point_cloud(sd, frame=CLIP_WORLD_FRAME_ID) # sensor → clip_world
84
+
85
+ # Get annotations transformed to clip-local world
86
+ anns_world = loader.annotations_for_sample(sample.token, frame=CLIP_WORLD_FRAME_ID)
78
87
 
79
- # Get annotations in ego frame (for training)
80
- anns_ego = loader.annotations_for_sample(sample.token, frame="ego")
88
+ # Or to a specific sensor's frame
89
+ anns_lidar = loader.annotations_for_sample(sample.token, frame="LIDAR_TOP")
81
90
 
82
- # Get the transform matrix directly
83
- T = loader.get_transform(sd, from_frame="sensor", to_frame="world")
91
+ # Get the transform matrix directly (sd is required when clip_world is involved)
92
+ T = loader.get_transform(loader.sensor_frame_id(sd), CLIP_WORLD_FRAME_ID, sd=sd)
84
93
  pts_world = T.apply(pts_sensor[:, :3]) # or use T.as_matrix for 4x4
85
94
  ```
86
95
 
@@ -35,22 +35,31 @@ for sample in loader.samples:
35
35
 
36
36
  ### Coordinate frame transforms
37
37
 
38
- Point clouds and annotations can be loaded in different coordinate frames:
38
+ Frames are real frame_id strings: any node in the calibration tree (e.g.
39
+ `"LIDAR_TOP"`, `"base_link"`, `"cam_front"`) plus the constant
40
+ `"clip_world"` (the SLAM-anchored clip-local world). The legacy aliases
41
+ `"sensor"`, `"ego"`, `"world"` are not accepted.
39
42
 
40
43
  ```python
44
+ from x4d_devkit import ClipLoader
45
+ from x4d_devkit.core.loader import CLIP_WORLD_FRAME_ID
46
+
41
47
  loader = ClipLoader("/path/to/clip")
42
48
  sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
43
49
 
44
50
  # Load point cloud in different frames
45
- pts_sensor = loader.load_point_cloud(sd, frame="sensor") # raw (default)
46
- pts_ego = loader.load_point_cloud(sd, frame="ego") # sensor → ego
47
- pts_world = loader.load_point_cloud(sd, frame="world") # sensor → world
51
+ pts_sensor = loader.load_point_cloud(sd) # raw sensor (default)
52
+ pts_ego = loader.load_point_cloud(sd, frame=loader.ego_pose_frame_id) # sensor → ego
53
+ pts_world = loader.load_point_cloud(sd, frame=CLIP_WORLD_FRAME_ID) # sensor → clip_world
54
+
55
+ # Get annotations transformed to clip-local world
56
+ anns_world = loader.annotations_for_sample(sample.token, frame=CLIP_WORLD_FRAME_ID)
48
57
 
49
- # Get annotations in ego frame (for training)
50
- anns_ego = loader.annotations_for_sample(sample.token, frame="ego")
58
+ # Or to a specific sensor's frame
59
+ anns_lidar = loader.annotations_for_sample(sample.token, frame="LIDAR_TOP")
51
60
 
52
- # Get the transform matrix directly
53
- T = loader.get_transform(sd, from_frame="sensor", to_frame="world")
61
+ # Get the transform matrix directly (sd is required when clip_world is involved)
62
+ T = loader.get_transform(loader.sensor_frame_id(sd), CLIP_WORLD_FRAME_ID, sd=sd)
54
63
  pts_world = T.apply(pts_sensor[:, :3]) # or use T.as_matrix for 4x4
55
64
  ```
56
65
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "x4d-devkit"
7
- version = "0.11.1"
7
+ version = "0.12.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"
@@ -1,7 +1,12 @@
1
- """Tests for ClipLoader coordinate frame support.
1
+ """Tests for ClipLoader coordinate frame support — frame-graph (post-magic-string) API.
2
2
 
3
- Unit tests use synthetic data (no nuScenes dependency).
4
- Integration tests require nuScenes mini data.
3
+ The new contract: every frame is a real frame_id string (a node in the
4
+ calibration tree) plus the constant ``"clip_world"`` for the SLAM-anchored
5
+ clip-local world. The legacy magic strings ``"sensor"``, ``"ego"``, ``"world"``
6
+ are rejected to remove the "did you mean my-actual-frame_id-string?" foot-gun.
7
+
8
+ Unit tests use synthetic data (no nuScenes dependency). Integration tests
9
+ require nuScenes mini data.
5
10
  """
6
11
 
7
12
  import json
@@ -12,8 +17,9 @@ import numpy as np
12
17
  import pytest
13
18
 
14
19
  from x4d_devkit.core.loader import ClipLoader
15
- from x4d_devkit.core.models import Annotation, SampleData
16
- from x4d_devkit.core.transform import Transform, rotation_matrix_to_quat
20
+
21
+
22
+ CLIP_WORLD = "clip_world"
17
23
 
18
24
 
19
25
  # ---------------------------------------------------------------------------
@@ -157,7 +163,7 @@ def _make_synthetic_clip(tmp_path: Path) -> Path:
157
163
  pts.tofile(str(lidar_dir / "frame0.bin"))
158
164
  pts.tofile(str(lidar_dir / "frame1.bin"))
159
165
 
160
- # -- annotation.json (v0.5: per-sample ego frame) --
166
+ # -- annotation.json (v0.5: per-sample ego frame; here ann_frame == base_link) --
161
167
  # s-0 ego at origin; s-1 ego at (10, 0, 0). Same world pos (5,3,0) and (10,3,0)
162
168
  # for the two frames maps to ego (5,3,0) and (0,3,0) respectively.
163
169
  (clip_dir / "annotation.json").write_text(json.dumps([
@@ -224,186 +230,220 @@ def loader(synthetic_clip):
224
230
 
225
231
 
226
232
  # ---------------------------------------------------------------------------
227
- # Unit tests: load_point_cloud with frame parameter
233
+ # Frame-id helpers (the user-facing way to discover frame names)
234
+ # ---------------------------------------------------------------------------
235
+
236
+ class TestFrameIdHelpers:
237
+ def test_ego_pose_frame_id_exposes_ego_frame(self, loader):
238
+ # v0.2 fallback chain: meta.ego_pose_frame_id None → annotation_frame_id None → "base_link".
239
+ assert loader.ego_pose_frame_id == "base_link"
240
+
241
+ def test_annotation_frame_id_exposed(self, loader):
242
+ assert loader.annotation_frame_id == "base_link"
243
+
244
+ def test_sensor_frame_id_returns_channel_frame(self, loader):
245
+ sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
246
+ # v0.2 has no per-sensor frame_id, falls back to channel name.
247
+ assert loader.sensor_frame_id(sd) == "LIDAR_TOP"
248
+
249
+ def test_clip_world_constant_is_string_literal(self):
250
+ # The spec pins this name. Any consumer can pass it as a regular frame.
251
+ from x4d_devkit.core.loader import CLIP_WORLD_FRAME_ID
252
+ assert CLIP_WORLD_FRAME_ID == "clip_world"
253
+
254
+
255
+ # ---------------------------------------------------------------------------
256
+ # load_point_cloud — explicit frame_id, no magic strings
228
257
  # ---------------------------------------------------------------------------
229
258
 
230
259
  class TestLoadPointCloudFrames:
231
- def test_sensor_frame_default(self, loader):
232
- """Default frame should return raw sensor coordinates."""
260
+ def test_no_frame_returns_raw_sensor(self, loader):
261
+ """Default (no frame=) returns the raw sensor data unchanged."""
233
262
  sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
234
263
  pts = loader.load_point_cloud(sd)
235
264
  assert pts.shape == (4, 4)
236
265
  np.testing.assert_allclose(pts[0, :3], [1.0, 0.0, 0.0], atol=1e-6)
237
266
 
238
- def test_sensor_frame_explicit(self, loader):
239
- """Explicit frame='sensor' should match default."""
267
+ def test_frame_equal_to_sensor_frame_is_noop(self, loader):
268
+ """Passing the sensor's own frame_id matches the no-frame default."""
240
269
  sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
241
270
  pts_default = loader.load_point_cloud(sd)
242
- pts_sensor = loader.load_point_cloud(sd, frame="sensor")
243
- np.testing.assert_array_equal(pts_default, pts_sensor)
271
+ pts_explicit = loader.load_point_cloud(sd, frame=loader.sensor_frame_id(sd))
272
+ np.testing.assert_array_equal(pts_default, pts_explicit)
244
273
 
245
- def test_ego_frame(self, loader):
246
- """frame='ego' applies sensor-to-ego transform (z+1 for LIDAR_TOP)."""
274
+ def test_frame_equal_to_ego_applies_calibration(self, loader):
275
+ """frame=ego_pose_frame_id applies sensor-to-ego (z+1 for LIDAR_TOP)."""
247
276
  sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
248
- pts = loader.load_point_cloud(sd, frame="ego")
249
- # LiDAR is at z=1 in ego frame, so sensor point (1,0,0) -> ego (1,0,1)
277
+ pts = loader.load_point_cloud(sd, frame=loader.ego_pose_frame_id)
250
278
  np.testing.assert_allclose(pts[0, :3], [1.0, 0.0, 1.0], atol=1e-5)
251
- # sensor (0,0,1) -> ego (0,0,2)
252
279
  np.testing.assert_allclose(pts[2, :3], [0.0, 0.0, 2.0], atol=1e-5)
253
280
 
254
- def test_world_frame_first_ego(self, loader):
255
- """frame='world' for first frame (ego at origin): same as ego frame."""
281
+ def test_frame_clip_world_first_ego(self, loader):
282
+ """frame='clip_world' for the first ego pose (at origin) ego frame."""
256
283
  sd = loader.sample_data_for_channel("LIDAR_TOP")[0] # ep-0 at origin
257
- pts_ego = loader.load_point_cloud(sd, frame="ego")
258
- pts_world = loader.load_point_cloud(sd, frame="world")
284
+ pts_ego = loader.load_point_cloud(sd, frame=loader.ego_pose_frame_id)
285
+ pts_world = loader.load_point_cloud(sd, frame=CLIP_WORLD)
259
286
  np.testing.assert_allclose(pts_world[:, :3], pts_ego[:, :3], atol=1e-5)
260
287
 
261
- def test_world_frame_second_ego(self, loader):
262
- """frame='world' for second frame (ego at x=10): adds ego offset."""
288
+ def test_frame_clip_world_second_ego(self, loader):
289
+ """frame='clip_world' for the second ego pose adds the ego offset."""
263
290
  sd = loader.sample_data_for_channel("LIDAR_TOP")[1] # ep-1 at x=10
264
- pts = loader.load_point_cloud(sd, frame="world")
265
- # sensor (1,0,0) -> ego (1,0,1) -> world (11,0,1)
291
+ pts = loader.load_point_cloud(sd, frame=CLIP_WORLD)
266
292
  np.testing.assert_allclose(pts[0, :3], [11.0, 0.0, 1.0], atol=1e-5)
267
293
 
268
294
  def test_intensity_preserved(self, loader):
269
- """Non-xyz columns should not be modified."""
295
+ """Non-xyz columns are passed through unchanged."""
270
296
  sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
271
- pts = loader.load_point_cloud(sd, frame="world")
297
+ pts = loader.load_point_cloud(sd, frame=CLIP_WORLD)
272
298
  np.testing.assert_allclose(pts[:, 3], 0.5, atol=1e-6)
273
299
 
274
- def test_invalid_frame_raises(self, loader):
300
+ def test_magic_string_world_rejected(self, loader):
301
+ """The legacy magic string 'world' must be rejected with a hint."""
275
302
  sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
276
- with pytest.raises(ValueError, match="Invalid frame"):
277
- loader.load_point_cloud(sd, frame="invalid")
303
+ with pytest.raises(ValueError, match=r"clip_world"):
304
+ loader.load_point_cloud(sd, frame="world")
305
+
306
+ def test_magic_string_ego_rejected(self, loader):
307
+ sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
308
+ with pytest.raises(ValueError, match=r"ego_pose_frame_id"):
309
+ loader.load_point_cloud(sd, frame="ego")
310
+
311
+ def test_magic_string_sensor_rejected(self, loader):
312
+ sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
313
+ with pytest.raises(ValueError, match=r"sensor_frame_id"):
314
+ loader.load_point_cloud(sd, frame="sensor")
315
+
316
+ def test_unknown_frame_id_raises_keyerror(self, loader):
317
+ sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
318
+ with pytest.raises(KeyError, match=r"not_a_real_frame"):
319
+ loader.load_point_cloud(sd, frame="not_a_real_frame")
278
320
 
279
321
 
280
322
  # ---------------------------------------------------------------------------
281
- # Unit tests: annotations_for_sample with frame parameter
323
+ # annotations_for_sample explicit frame_id, no magic strings
282
324
  # ---------------------------------------------------------------------------
283
325
 
284
326
  class TestAnnotationsForSampleFrames:
285
- def test_ego_frame_default(self, loader):
286
- """Default frame should return annotations in per-sample ego coordinates (v0.5 native)."""
327
+ def test_no_frame_returns_native(self, loader):
328
+ """Default (no frame=) returns annotations in the stored frame, unchanged."""
287
329
  anns = loader.annotations_for_sample("s-0")
288
330
  assert len(anns) == 1
289
331
  np.testing.assert_allclose(anns[0].translation, [5.0, 3.0, 0.0])
290
332
 
291
- def test_ego_frame_explicit(self, loader):
292
- """Explicit frame='ego' should match default."""
333
+ def test_frame_equal_to_annotation_frame_is_noop(self, loader):
334
+ """Passing annotation_frame_id matches the no-frame default."""
293
335
  anns_default = loader.annotations_for_sample("s-0")
294
- anns_ego = loader.annotations_for_sample("s-0", frame="ego")
295
- assert len(anns_default) == len(anns_ego)
296
- np.testing.assert_allclose(anns_default[0].translation, anns_ego[0].translation)
297
-
298
- def test_world_frame_first_sample(self, loader):
299
- """frame='world' for first sample (ego at origin): same as ego."""
300
- anns = loader.annotations_for_sample("s-0", frame="world")
301
- # ego at origin -> ego-to-world is identity
336
+ anns_explicit = loader.annotations_for_sample("s-0", frame=loader.annotation_frame_id)
337
+ np.testing.assert_allclose(anns_default[0].translation, anns_explicit[0].translation)
338
+
339
+ def test_frame_clip_world_first_sample(self, loader):
340
+ """frame='clip_world' on the first sample (ego at origin) ≡ stored ego."""
341
+ anns = loader.annotations_for_sample("s-0", frame=CLIP_WORLD)
302
342
  np.testing.assert_allclose(anns[0].translation, [5.0, 3.0, 0.0], atol=1e-5)
303
343
 
304
- def test_world_frame_second_sample(self, loader):
305
- """frame='world' for second sample (ego at x=10): adds ego offset."""
306
- anns = loader.annotations_for_sample("s-1", frame="world")
307
- # ego frame (0,3,0), ego at (10,0,0) -> world (10,3,0)
344
+ def test_frame_clip_world_second_sample(self, loader):
345
+ """frame='clip_world' on the second sample (ego at x=10) shifts by ego offset."""
346
+ anns = loader.annotations_for_sample("s-1", frame=CLIP_WORLD)
308
347
  np.testing.assert_allclose(anns[0].translation, [10.0, 3.0, 0.0], atol=1e-5)
309
348
 
310
- def test_ego_frame_velocity_unchanged(self, loader):
311
- """Native (ego) frame: velocity returned as stored."""
312
- anns = loader.annotations_for_sample("s-0", frame="ego")
349
+ def test_native_velocity_unchanged(self, loader):
350
+ anns = loader.annotations_for_sample("s-0")
313
351
  np.testing.assert_allclose(anns[0].velocity, [5.0, 0.0, 0.0], atol=1e-5)
314
352
 
315
- def test_world_frame_preserves_other_fields(self, loader):
316
- """Non-pose fields should be preserved across frame transforms."""
317
- anns_ego = loader.annotations_for_sample("s-0", frame="ego")
318
- anns_world = loader.annotations_for_sample("s-0", frame="world")
319
- assert anns_ego[0].token == anns_world[0].token
320
- assert anns_ego[0].category == anns_world[0].category
321
- np.testing.assert_allclose(anns_ego[0].size, anns_world[0].size)
322
- assert anns_ego[0].num_lidar_pts == anns_world[0].num_lidar_pts
323
-
324
- def test_world_frame_returns_new_objects(self, loader):
325
- """Transformed annotations should be new objects, not mutated originals."""
326
- anns_ego = loader.annotations_for_sample("s-1", frame="ego")
327
- anns_world = loader.annotations_for_sample("s-1", frame="world")
328
- # Originals (ego, native) should be unmodified
329
- np.testing.assert_allclose(anns_ego[0].translation, [0.0, 3.0, 0.0])
330
- # World should be different
331
- assert not np.allclose(anns_world[0].translation, anns_ego[0].translation, atol=0.1)
332
-
333
- def test_sensor_frame_raises(self, loader):
334
- with pytest.raises(ValueError, match="ambiguous"):
353
+ def test_clip_world_preserves_other_fields(self, loader):
354
+ anns_native = loader.annotations_for_sample("s-0")
355
+ anns_world = loader.annotations_for_sample("s-0", frame=CLIP_WORLD)
356
+ assert anns_native[0].token == anns_world[0].token
357
+ assert anns_native[0].category == anns_world[0].category
358
+ np.testing.assert_allclose(anns_native[0].size, anns_world[0].size)
359
+ assert anns_native[0].num_lidar_pts == anns_world[0].num_lidar_pts
360
+
361
+ def test_returns_new_objects(self, loader):
362
+ """Transformed annotations are new objects; originals untouched."""
363
+ anns_native = loader.annotations_for_sample("s-1")
364
+ anns_world = loader.annotations_for_sample("s-1", frame=CLIP_WORLD)
365
+ np.testing.assert_allclose(anns_native[0].translation, [0.0, 3.0, 0.0])
366
+ assert not np.allclose(anns_world[0].translation, anns_native[0].translation, atol=0.1)
367
+
368
+ def test_frame_sensor_id_transforms_to_that_sensor(self, loader):
369
+ """Passing a sensor's frame_id transforms annotations into that sensor's frame."""
370
+ # LIDAR_TOP is at (0,0,1) in ego frame. Annotation at ego (5,3,0) →
371
+ # LIDAR_TOP frame: subtract z=1 → (5,3,-1).
372
+ anns = loader.annotations_for_sample("s-0", frame="LIDAR_TOP")
373
+ np.testing.assert_allclose(anns[0].translation, [5.0, 3.0, -1.0], atol=1e-5)
374
+
375
+ def test_magic_string_sensor_rejected(self, loader):
376
+ with pytest.raises(ValueError, match=r"sensor_frame_id"):
335
377
  loader.annotations_for_sample("s-0", frame="sensor")
336
378
 
337
- def test_invalid_frame_raises(self, loader):
338
- with pytest.raises(ValueError, match="Invalid frame"):
339
- loader.annotations_for_sample("s-0", frame="invalid")
379
+ def test_magic_string_ego_rejected(self, loader):
380
+ with pytest.raises(ValueError, match=r"ego_pose_frame_id"):
381
+ loader.annotations_for_sample("s-0", frame="ego")
382
+
383
+ def test_magic_string_world_rejected(self, loader):
384
+ with pytest.raises(ValueError, match=r"clip_world"):
385
+ loader.annotations_for_sample("s-0", frame="world")
386
+
387
+ def test_unknown_frame_id_raises(self, loader):
388
+ with pytest.raises((KeyError, ValueError), match=r"not_a_real_frame"):
389
+ loader.annotations_for_sample("s-0", frame="not_a_real_frame")
340
390
 
341
391
  def test_empty_sample_returns_empty(self, loader):
342
- """Non-existent sample returns empty list regardless of frame."""
343
- assert loader.annotations_for_sample("nonexistent", frame="ego") == []
392
+ assert loader.annotations_for_sample("nonexistent") == []
393
+ assert loader.annotations_for_sample("nonexistent", frame=CLIP_WORLD) == []
344
394
 
345
395
 
346
396
  # ---------------------------------------------------------------------------
347
- # Unit tests: get_transform
397
+ # get_transform frame_id × frame_id, sd carries dynamic context
348
398
  # ---------------------------------------------------------------------------
349
399
 
350
400
  class TestGetTransform:
351
401
  def test_identity_same_frame(self, loader):
352
- sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
353
- T = loader.get_transform(sd, from_frame="sensor", to_frame="sensor")
402
+ T = loader.get_transform("LIDAR_TOP", "LIDAR_TOP")
354
403
  np.testing.assert_allclose(T.as_matrix, np.eye(4), atol=1e-10)
355
404
 
356
- def test_sensor_to_ego(self, loader):
357
- sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
358
- T = loader.get_transform(sd, from_frame="sensor", to_frame="ego")
359
- # LiDAR at z=1
405
+ def test_static_sensor_to_ego(self, loader):
406
+ # Static-only: no sd needed.
407
+ T = loader.get_transform("LIDAR_TOP", loader.ego_pose_frame_id)
360
408
  np.testing.assert_allclose(T.translation, [0.0, 0.0, 1.0], atol=1e-10)
361
409
 
362
- def test_sensor_to_world(self, loader):
410
+ def test_static_sensor_to_sensor(self, loader):
411
+ # LIDAR_TOP at (0,0,1) and CAM_FRONT at (0.5,0,0.8) in ego frame.
412
+ # T_CAM_from_LIDAR = T_CAM_from_ego @ T_ego_from_LIDAR
413
+ # = (-0.5, 0, -0.8) ∘ (0, 0, +1) = (-0.5, 0, +0.2)
414
+ T = loader.get_transform("LIDAR_TOP", "CAM_FRONT")
415
+ np.testing.assert_allclose(T.translation, [-0.5, 0.0, 0.2], atol=1e-10)
416
+
417
+ def test_dynamic_sensor_to_clip_world_requires_sd(self, loader):
363
418
  sd = loader.sample_data_for_channel("LIDAR_TOP")[1] # ego at x=10
364
- T = loader.get_transform(sd, from_frame="sensor", to_frame="world")
365
- # sensor to ego (z+1) then ego to world (x+10)
419
+ T = loader.get_transform("LIDAR_TOP", CLIP_WORLD, sd=sd)
420
+ # sensor ego (z+1) world (x+10) → (10, 0, 1)
366
421
  np.testing.assert_allclose(T.translation, [10.0, 0.0, 1.0], atol=1e-10)
367
422
 
368
- def test_ego_to_world(self, loader):
369
- sd = loader.sample_data_for_channel("LIDAR_TOP")[1]
370
- T = loader.get_transform(sd, from_frame="ego", to_frame="world")
371
- np.testing.assert_allclose(T.translation, [10.0, 0.0, 0.0], atol=1e-10)
423
+ def test_clip_world_without_sd_raises(self, loader):
424
+ with pytest.raises(ValueError, match=r"clip_world"):
425
+ loader.get_transform("LIDAR_TOP", CLIP_WORLD)
372
426
 
373
- def test_round_trip(self, loader):
374
- """sensor→world→sensor should be identity."""
427
+ def test_round_trip_through_clip_world(self, loader):
375
428
  sd = loader.sample_data_for_channel("LIDAR_TOP")[1]
376
- T_fwd = loader.get_transform(sd, from_frame="sensor", to_frame="world")
377
- T_rev = loader.get_transform(sd, from_frame="world", to_frame="sensor")
378
- T_round = T_fwd @ T_rev
379
- np.testing.assert_allclose(T_round.as_matrix, np.eye(4), atol=1e-10)
429
+ T_fwd = loader.get_transform("LIDAR_TOP", CLIP_WORLD, sd=sd)
430
+ T_rev = loader.get_transform(CLIP_WORLD, "LIDAR_TOP", sd=sd)
431
+ np.testing.assert_allclose((T_fwd @ T_rev).as_matrix, np.eye(4), atol=1e-10)
380
432
 
381
- def test_all_six_combinations(self, loader):
382
- """All from/to combinations should work without error."""
433
+ def test_magic_string_rejected(self, loader):
383
434
  sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
384
- frames = ["sensor", "ego", "world"]
385
- for f in frames:
386
- for t in frames:
387
- T = loader.get_transform(sd, from_frame=f, to_frame=t)
388
- assert T.as_matrix.shape == (4, 4)
435
+ with pytest.raises(ValueError, match=r"clip_world"):
436
+ loader.get_transform("sensor", "world", sd=sd)
389
437
 
390
- def test_invalid_from_frame_raises(self, loader):
391
- sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
392
- with pytest.raises(ValueError, match="Invalid from_frame"):
393
- loader.get_transform(sd, from_frame="bad", to_frame="ego")
394
-
395
- def test_invalid_to_frame_raises(self, loader):
396
- sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
397
- with pytest.raises(ValueError, match="Invalid to_frame"):
398
- loader.get_transform(sd, from_frame="sensor", to_frame="bad")
438
+ def test_unknown_frame_id_raises_keyerror(self, loader):
439
+ with pytest.raises(KeyError, match=r"not_a_real_frame"):
440
+ loader.get_transform("LIDAR_TOP", "not_a_real_frame")
399
441
 
400
442
  def test_consistency_with_load_point_cloud(self, loader):
401
- """get_transform result should match load_point_cloud frame output."""
402
443
  sd = loader.sample_data_for_channel("LIDAR_TOP")[1]
403
- pts_sensor = loader.load_point_cloud(sd, frame="sensor")
404
- pts_world = loader.load_point_cloud(sd, frame="world")
405
-
406
- T = loader.get_transform(sd, from_frame="sensor", to_frame="world")
444
+ pts_sensor = loader.load_point_cloud(sd)
445
+ pts_world = loader.load_point_cloud(sd, frame=CLIP_WORLD)
446
+ T = loader.get_transform("LIDAR_TOP", CLIP_WORLD, sd=sd)
407
447
  pts_manual = T.apply(pts_sensor[:, :3].astype(np.float64)).astype(np.float32)
408
448
  np.testing.assert_allclose(pts_world[:, :3], pts_manual, atol=1e-5)
409
449
 
@@ -434,25 +474,28 @@ def nuscenes_loader(tmp_path_factory):
434
474
  @pytest.mark.skipif(not NUSCENES_AVAILABLE, reason="nuScenes data not found")
435
475
  class TestCoordinateFramesIntegration:
436
476
  def test_point_cloud_round_trip(self, nuscenes_loader):
437
- """sensor→world→sensor should recover original points."""
477
+ """sensor clip_world sensor recovers original points."""
438
478
  sd = next(
439
479
  s for s in nuscenes_loader.sample_data_for_channel("LIDAR_TOP") if s.is_keyframe
440
480
  )
441
- pts_sensor = nuscenes_loader.load_point_cloud(sd, frame="sensor")
442
- T = nuscenes_loader.get_transform(sd, from_frame="world", to_frame="sensor")
443
- pts_world = nuscenes_loader.load_point_cloud(sd, frame="world")
444
- recovered = T.apply(pts_world[:, :3].astype(np.float64)).astype(np.float32)
481
+ pts_sensor = nuscenes_loader.load_point_cloud(sd)
482
+ pts_world = nuscenes_loader.load_point_cloud(sd, frame=CLIP_WORLD)
483
+ T_back = nuscenes_loader.get_transform(CLIP_WORLD, "LIDAR_TOP", sd=sd)
484
+ recovered = T_back.apply(pts_world[:, :3].astype(np.float64)).astype(np.float32)
445
485
  np.testing.assert_allclose(recovered, pts_sensor[:, :3], atol=1e-4)
446
486
 
447
487
  def test_ego_annotations_near_ego(self, nuscenes_loader):
448
- """Ego-frame annotations should be centered around the ego vehicle."""
449
488
  sample = nuscenes_loader.samples[0]
450
- anns_world = nuscenes_loader.annotations_for_sample(sample.token, frame="world")
451
- anns_ego = nuscenes_loader.annotations_for_sample(sample.token, frame="ego")
452
- if not anns_world:
489
+ anns_native = nuscenes_loader.annotations_for_sample(sample.token)
490
+ anns_world = nuscenes_loader.annotations_for_sample(sample.token, frame=CLIP_WORLD)
491
+ if not anns_native:
453
492
  pytest.skip("No annotations in first sample")
454
-
455
- # In ego frame, nearby objects should be within reasonable range
456
- for ann in anns_ego:
457
- dist = np.linalg.norm(ann.translation[:2]) # xy distance from ego
458
- assert dist < 200, f"Annotation too far in ego frame: {dist}m"
493
+ for ann in anns_native:
494
+ dist = np.linalg.norm(ann.translation[:2])
495
+ assert dist < 200, f"Annotation too far in native frame: {dist}m"
496
+ # World frame should differ for at least one annotation (ego usually not at origin).
497
+ diffs = [
498
+ np.linalg.norm(an.translation - aw.translation)
499
+ for an, aw in zip(anns_native, anns_world)
500
+ ]
501
+ assert max(diffs) > 0 # at minimum, different positions
@@ -274,7 +274,7 @@ def test_annotations_for_sample_unknown_frame_id_raises(tmp_path):
274
274
  }],
275
275
  )
276
276
  loader = ClipLoader(tmp_path)
277
- with pytest.raises(ValueError, match="Invalid frame"):
277
+ with pytest.raises(KeyError, match="never_exists_frame"):
278
278
  loader.annotations_for_sample("s-0", frame="never_exists_frame")
279
279
 
280
280
 
@@ -1,11 +1,20 @@
1
- """ClipLoader: load an X4D clip directory into typed dataclasses."""
1
+ """ClipLoader: load an X4D clip directory into typed dataclasses.
2
+
3
+ Coordinate frames in this loader are *real frame_id strings only* — every
4
+ frame is either a node in the clip's calibration tree (e.g. ``"LIDAR_TOP"``,
5
+ ``"base_link"``, ``"cam_front"``) or the constant ``"clip_world"`` for the
6
+ SLAM-anchored clip-local world. The legacy magic aliases ``"sensor"``,
7
+ ``"ego"``, ``"world"`` are no longer accepted; use the helpers
8
+ :meth:`ClipLoader.sensor_frame_id`, :attr:`ClipLoader.ego_pose_frame_id`,
9
+ and :data:`CLIP_WORLD_FRAME_ID` instead.
10
+ """
2
11
 
3
12
  from __future__ import annotations
4
13
 
5
14
  import json
6
15
  from dataclasses import replace
7
16
  from pathlib import Path
8
- from typing import Any, Literal
17
+ from typing import Any
9
18
 
10
19
  import numpy as np
11
20
 
@@ -21,14 +30,34 @@ from x4d_devkit.core.models import (
21
30
  )
22
31
  from x4d_devkit.core.transform import Transform, TransformChain, rotation_matrix_to_quat
23
32
 
24
- FrameType = Literal["sensor", "ego", "world"]
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"}
33
+
34
+ CLIP_WORLD_FRAME_ID = "clip_world"
35
+ """The constant frame_id of the SLAM-anchored clip-local world.
36
+
37
+ Defined by the X-4D dataset spec (v0.8+). Not a node in
38
+ ``calibrated_sensor.json``; it is the dynamic edge wired through
39
+ ``ego_pose.json`` (``ego_pose[t]: clip_world ego_pose_frame_id``).
40
+ """
41
+
42
+ _MAGIC_FRAME_REPLACEMENTS = {
43
+ "sensor": "loader.sensor_frame_id(sd) (e.g. 'LIDAR_TOP')",
44
+ "ego": "loader.ego_pose_frame_id (e.g. 'base_link')",
45
+ "world": f"the constant {CLIP_WORLD_FRAME_ID!r} "
46
+ "(also exported as x4d_devkit.core.loader.CLIP_WORLD_FRAME_ID)",
47
+ }
48
+
49
+
50
+ def _reject_magic_frame(frame: str) -> None:
51
+ """Reject the legacy magic aliases ('sensor' / 'ego' / 'world') with a hint."""
52
+ if frame in _MAGIC_FRAME_REPLACEMENTS:
53
+ replacements = "; ".join(
54
+ f"{alias!r} → {hint}" for alias, hint in _MAGIC_FRAME_REPLACEMENTS.items()
55
+ )
56
+ raise ValueError(
57
+ f"frame {frame!r} is not a valid frame_id — the legacy magic aliases "
58
+ f"'sensor', 'ego', 'world' were removed. Use real frame_ids instead: "
59
+ f"{replacements}."
60
+ )
32
61
 
33
62
 
34
63
  class SchemaVersionMismatch(ValueError):
@@ -76,12 +105,13 @@ class ClipLoader:
76
105
  for sample in loader.samples:
77
106
  for sd in loader.sample_data_for_sample(sample.token):
78
107
  if sd.channel in lidar_channels:
79
- pts = loader.load_point_cloud(sd, frame="ego")
108
+ pts = loader.load_point_cloud(sd, frame=loader.ego_pose_frame_id)
80
109
 
81
- # Get annotations in their stored frame (loader.annotation_frame_id)
110
+ # Annotations in their stored frame:
82
111
  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")
112
+ # Or transform to clip-local world / a specific sensor frame:
113
+ anns_world = loader.annotations_for_sample(sample.token, frame=CLIP_WORLD_FRAME_ID)
114
+ anns_lidar = loader.annotations_for_sample(sample.token, frame="LIDAR_TOP")
85
115
  """
86
116
 
87
117
  def __init__(
@@ -288,8 +318,7 @@ class ClipLoader:
288
318
 
289
319
  Returns ``meta.annotation_frame_id`` if explicitly set (v0.6+ clips).
290
320
  Pre-v0.6 clips fall back to ``"base_link"`` — the per-sample ego frame
291
- all earlier loader code already assumed (``annotations_for_sample(frame="ego")``
292
- was a no-op transform).
321
+ all earlier loader code already assumed.
293
322
  """
294
323
  if self._meta.annotation_frame_id is not None:
295
324
  return self._meta.annotation_frame_id
@@ -319,6 +348,15 @@ class ClipLoader:
319
348
  return info.frame_id
320
349
  return channel
321
350
 
351
+ def sensor_frame_id(self, sd: SampleData) -> str:
352
+ """ROS frame_id of the sensor that produced ``sd``.
353
+
354
+ Sugar for ``frame_id_for_channel(sd.channel)`` — the canonical way to
355
+ get the source frame_id for a piece of sample_data when calling
356
+ :meth:`load_point_cloud` / :meth:`get_transform`.
357
+ """
358
+ return self.frame_id_for_channel(sd.channel)
359
+
322
360
  def require_schema_version(self, expected: "str | set[str]") -> None:
323
361
  """Raise ``SchemaVersionMismatch`` if the clip's schema_version isn't in ``expected``.
324
362
 
@@ -361,7 +399,7 @@ class ClipLoader:
361
399
 
362
400
  @property
363
401
  def annotations(self) -> list[Annotation]:
364
- """All annotations in their **native frame** (ego since v0.5; world for v0.4 clips).
402
+ """All annotations in their **native frame** (``annotation_frame_id``).
365
403
 
366
404
  Use ``annotations_for_sample(token, frame=...)`` to request a specific frame.
367
405
  """
@@ -411,94 +449,54 @@ class ClipLoader:
411
449
  def annotations_for_sample(
412
450
  self,
413
451
  sample_token: str,
414
- frame: str = "ego",
452
+ frame: str | None = None,
415
453
  ) -> list[Annotation]:
416
- """All annotations for a given sample, transformed to the requested frame.
454
+ """All annotations for a given sample, optionally transformed to ``frame``.
417
455
 
418
456
  Args:
419
457
  sample_token: The sample token.
420
- frame: Target coordinate frame. Accepted values:
421
- - ``"ego"`` / ``"base_link"`` (default): vehicle body frame.
422
- - ``"world"``: clip-local world frame (composes with ego_pose).
423
- - any sensor's ROS frame_id present in the calibration tree
424
- (e.g. ``"LIDAR_TOP"``, ``"camera-front"``).
425
- - ``"sensor"`` is rejected (ambiguous — no single sensor implied).
458
+ frame: Target frame_id. ``None`` (default) returns annotations in
459
+ their stored frame (``loader.annotation_frame_id``). Any other
460
+ value must be a real frame_id in the calibration tree, or the
461
+ constant :data:`CLIP_WORLD_FRAME_ID`. The legacy magic aliases
462
+ ``"sensor"``/``"ego"``/``"world"`` raise ``ValueError``.
426
463
 
427
464
  Returns:
428
465
  List of Annotation objects in the requested frame.
429
466
  """
430
- if frame == "sensor":
431
- raise ValueError(
432
- "frame='sensor' is ambiguous for annotations; pass an explicit "
433
- "frame_id (e.g. 'LIDAR_TOP') or use 'ego' / 'base_link' / 'world'."
434
- )
467
+ if frame is not None:
468
+ _reject_magic_frame(frame)
435
469
 
436
470
  anns = self._ann_by_sample.get(sample_token, [])
437
- if not anns:
471
+ if not anns or frame is None or frame == self.annotation_frame_id:
438
472
  return anns
439
473
 
440
- T = self._transform_for_annotation_target(sample_token, frame)
441
- if T is None:
442
- return anns
474
+ sd = None
475
+ if frame == CLIP_WORLD_FRAME_ID:
476
+ sd = self._representative_sd_for_sample(sample_token)
477
+ T = self.get_transform(self.annotation_frame_id, frame, sd=sd)
443
478
  return [self._transform_annotation(a, T) for a in anns]
444
479
 
445
- def _transform_for_annotation_target(
446
- self, sample_token: str, target_frame: str
447
- ) -> Transform | None:
448
- """Build the transform from the clip's annotation frame to ``target_frame``.
449
-
450
- Returns ``None`` when the requested frame matches the storage frame
451
- (no transform needed).
452
- """
453
- ann_frame = self.annotation_frame_id # e.g. "base_link" or "LIDAR_TOP"
454
- if target_frame == ann_frame:
455
- return None
456
- if target_frame in _EGO_ALIASES and ann_frame in _EGO_ALIASES:
457
- return None
458
-
459
- chain = self._get_transform_chain()
460
- # T(ego ← annotation_frame): identity if annotation frame is ego/base_link;
461
- # else look up the sensor whose frame_id == ann_frame.
462
- if ann_frame in _EGO_ALIASES:
463
- ego_from_ann = Transform.identity()
464
- else:
465
- ego_from_ann = chain.get_ego_from_frame_id(ann_frame)
466
-
467
- # T(target ← ego)
468
- if target_frame in _EGO_ALIASES:
469
- target_from_ego = Transform.identity()
470
- elif target_frame == "world":
471
- target_from_ego = self._get_world_from_ego_for_sample(sample_token)
472
- else:
473
- try:
474
- ego_from_target = chain.get_ego_from_frame_id(target_frame)
475
- except KeyError as e:
476
- raise ValueError(
477
- f"Invalid frame {target_frame!r} for annotations: {e}"
478
- ) from None
479
- target_from_ego = ego_from_target.inverse
480
-
481
- return target_from_ego @ ego_from_ann
482
-
483
480
  def load_point_cloud(
484
481
  self,
485
482
  sd: SampleData,
486
- frame: FrameType = "sensor",
483
+ frame: str | None = None,
487
484
  ) -> np.ndarray:
488
485
  """Load point cloud binary file as (N, C) float32 array.
489
486
 
490
487
  Args:
491
488
  sd: The SampleData record for the point cloud.
492
- frame: Target coordinate frame.
493
- - "sensor" (default): raw sensor frame.
494
- - "ego": sensor ego via calibrated_sensor extrinsics.
495
- - "world": sensor → egoworld via extrinsics + ego_pose.
489
+ frame: Target frame_id. ``None`` (default) returns raw sensor
490
+ coordinates. Any other value must be a real frame_id in the
491
+ calibration tree, or :data:`CLIP_WORLD_FRAME_ID`. The legacy
492
+ magic aliases ``"sensor"``/``"ego"``/``"world"`` raise
493
+ ``ValueError``.
496
494
 
497
495
  Returns:
498
496
  Point cloud as (N, C) float32 array with xyz in the requested frame.
499
497
  """
500
- if frame not in _VALID_FRAMES:
501
- raise ValueError(f"Invalid frame '{frame}', must be one of {_VALID_FRAMES}")
498
+ if frame is not None:
499
+ _reject_magic_frame(frame)
502
500
 
503
501
  sensor_info = self._meta.sensors[sd.channel]
504
502
  if sensor_info.point_format is None:
@@ -512,53 +510,49 @@ class ClipLoader:
512
510
  raw = np.fromfile(str(path), dtype=np.float32)
513
511
  pts = raw.reshape(-1, num_fields)
514
512
 
515
- if frame == "sensor":
513
+ src_frame = self.sensor_frame_id(sd)
514
+ if frame is None or frame == src_frame:
516
515
  return pts
517
516
 
518
- T = self.get_transform(sd, from_frame="sensor", to_frame=frame)
517
+ T = self.get_transform(src_frame, frame, sd=sd)
519
518
  pts[:, :3] = T.apply(pts[:, :3].astype(np.float64)).astype(np.float32)
520
519
  return pts
521
520
 
522
521
  def get_transform(
523
522
  self,
524
- sd: SampleData,
525
- from_frame: FrameType = "sensor",
526
- to_frame: FrameType = "world",
523
+ from_frame: str,
524
+ to_frame: str,
525
+ *,
526
+ sd: SampleData | None = None,
527
527
  ) -> Transform:
528
- """Get a Transform between coordinate frames for a given sample_data.
528
+ """Build the SE(3) transform ``to_frame from_frame``.
529
+
530
+ Both arguments are real frame_id strings (calibration-tree nodes or
531
+ :data:`CLIP_WORLD_FRAME_ID`). When either side is ``CLIP_WORLD_FRAME_ID``
532
+ the transform is dynamic and ``sd`` (or another ego_pose-bearing
533
+ SampleData) must be supplied to fix the timestamp.
529
534
 
530
535
  Args:
531
- sd: The SampleData record (provides channel and ego_pose_token).
532
- from_frame: Source frame ("sensor", "ego", or "world").
533
- to_frame: Target frame ("sensor", "ego", or "world").
536
+ from_frame: Source frame_id.
537
+ to_frame: Destination frame_id.
538
+ sd: SampleData fixing the ego_pose for ``CLIP_WORLD_FRAME_ID``;
539
+ omit when both frames are static (in the calibration tree).
534
540
 
535
541
  Returns:
536
- Transform object. Use T.apply(points) or T.as_matrix for the 4x4 matrix.
537
- """
538
- if from_frame not in _VALID_FRAMES:
539
- raise ValueError(f"Invalid from_frame '{from_frame}', must be one of {_VALID_FRAMES}")
540
- if to_frame not in _VALID_FRAMES:
541
- raise ValueError(f"Invalid to_frame '{to_frame}', must be one of {_VALID_FRAMES}")
542
+ Transform such that ``p_to = T.apply(p_from)``.
542
543
 
544
+ Raises:
545
+ ValueError: if a magic alias is passed, or ``CLIP_WORLD_FRAME_ID``
546
+ is requested without ``sd``.
547
+ KeyError: if a frame_id is not in the calibration tree.
548
+ """
549
+ _reject_magic_frame(from_frame)
550
+ _reject_magic_frame(to_frame)
543
551
  if from_frame == to_frame:
544
552
  return Transform.identity()
545
-
546
- chain = self._get_transform_chain()
547
- ego_from_sensor = chain.get_ego_from_sensor(sd.channel)
548
- world_from_ego = chain.get_world_from_ego(sd.ego_pose_token)
549
-
550
- if from_frame == "sensor" and to_frame == "ego":
551
- return ego_from_sensor
552
- elif from_frame == "sensor" and to_frame == "world":
553
- return world_from_ego @ ego_from_sensor
554
- elif from_frame == "ego" and to_frame == "world":
555
- return world_from_ego
556
- elif from_frame == "ego" and to_frame == "sensor":
557
- return ego_from_sensor.inverse
558
- elif from_frame == "world" and to_frame == "ego":
559
- return world_from_ego.inverse
560
- else: # world -> sensor
561
- return (world_from_ego @ ego_from_sensor).inverse
553
+ T_root_from_src = self._root_from_frame(from_frame, sd=sd)
554
+ T_root_from_dst = self._root_from_frame(to_frame, sd=sd)
555
+ return T_root_from_dst.inverse @ T_root_from_src
562
556
 
563
557
  def load_image_path(self, sd: SampleData) -> Path:
564
558
  """Get the absolute path to an image file.
@@ -581,24 +575,42 @@ class ClipLoader:
581
575
  self._transform_chain = TransformChain(self)
582
576
  return self._transform_chain
583
577
 
584
- def _get_world_from_ego_for_sample(self, sample_token: str) -> Transform:
585
- """Get the world_from_ego transform for a sample.
578
+ def _root_from_frame(
579
+ self, frame_id: str, *, sd: SampleData | None = None,
580
+ ) -> Transform:
581
+ """Transform from ``frame_id`` to the calibration tree root.
586
582
 
587
- Uses the ego_pose from the LiDAR sample_data, or the first sample_data
588
- if no LiDAR channel exists.
583
+ For :data:`CLIP_WORLD_FRAME_ID` the result is dynamic (depends on
584
+ ``sd.ego_pose_token``); for any other frame it is the static walk
585
+ through ``calibrated_sensor.json``.
589
586
  """
587
+ chain = self._get_transform_chain()
588
+ if frame_id != CLIP_WORLD_FRAME_ID:
589
+ return chain.get_root_from_frame_id(frame_id)
590
+ if sd is None:
591
+ raise ValueError(
592
+ f"frame {CLIP_WORLD_FRAME_ID!r} is dynamic — pass sd=<SampleData> "
593
+ f"so the transform can be evaluated at the correct ego_pose."
594
+ )
595
+ ego_fid = self.ego_pose_frame_id
596
+ T_root_from_ego = chain.get_root_from_frame_id(ego_fid)
597
+ T_world_from_ego = chain.get_world_from_ego(sd.ego_pose_token)
598
+ # T_root_from_world = T_root_from_ego @ T_world_from_ego.inverse
599
+ return T_root_from_ego @ T_world_from_ego.inverse
600
+
601
+ def _representative_sd_for_sample(self, sample_token: str) -> SampleData:
602
+ """Pick a representative SampleData (preferring lidar) for a sample."""
590
603
  sds = self._sd_by_sample.get(sample_token, [])
591
604
  if not sds:
592
- raise ValueError(f"No sample_data found for sample {sample_token}")
593
-
594
- # Prefer LiDAR sample_data for the ego_pose
605
+ raise ValueError(
606
+ f"sample {sample_token!r} has no sample_data — cannot resolve "
607
+ f"{CLIP_WORLD_FRAME_ID!r}."
608
+ )
595
609
  cs_map = {cs.channel: cs for cs in self._calibrated_sensors}
596
- sd = next(
610
+ return next(
597
611
  (s for s in sds if cs_map.get(s.channel) and cs_map[s.channel].modality == "lidar"),
598
612
  sds[0],
599
613
  )
600
- chain = self._get_transform_chain()
601
- return chain.get_world_from_ego(sd.ego_pose_token)
602
614
 
603
615
  @staticmethod
604
616
  def _transform_annotation(ann: Annotation, T: Transform) -> Annotation:
@@ -275,7 +275,7 @@ class Annotation:
275
275
  via calibration, so per-sample timing is determined by ``sample_token``.
276
276
  For v0.5 clips without an explicit field, the convention is per-sample ego
277
277
  frame (== ``base_link``). Use ``ClipLoader.annotations_for_sample(..., frame=...)``
278
- to transform to ``"world"`` or any other frame_id in the calibration tree.
278
+ to transform to ``"clip_world"`` or any other frame_id in the calibration tree.
279
279
 
280
280
  Attributes:
281
281
  token: Unique annotation token.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: x4d-devkit
3
- Version: 0.11.1
3
+ Version: 0.12.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
@@ -65,22 +65,31 @@ for sample in loader.samples:
65
65
 
66
66
  ### Coordinate frame transforms
67
67
 
68
- Point clouds and annotations can be loaded in different coordinate frames:
68
+ Frames are real frame_id strings: any node in the calibration tree (e.g.
69
+ `"LIDAR_TOP"`, `"base_link"`, `"cam_front"`) plus the constant
70
+ `"clip_world"` (the SLAM-anchored clip-local world). The legacy aliases
71
+ `"sensor"`, `"ego"`, `"world"` are not accepted.
69
72
 
70
73
  ```python
74
+ from x4d_devkit import ClipLoader
75
+ from x4d_devkit.core.loader import CLIP_WORLD_FRAME_ID
76
+
71
77
  loader = ClipLoader("/path/to/clip")
72
78
  sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
73
79
 
74
80
  # Load point cloud in different frames
75
- pts_sensor = loader.load_point_cloud(sd, frame="sensor") # raw (default)
76
- pts_ego = loader.load_point_cloud(sd, frame="ego") # sensor → ego
77
- pts_world = loader.load_point_cloud(sd, frame="world") # sensor → world
81
+ pts_sensor = loader.load_point_cloud(sd) # raw sensor (default)
82
+ pts_ego = loader.load_point_cloud(sd, frame=loader.ego_pose_frame_id) # sensor → ego
83
+ pts_world = loader.load_point_cloud(sd, frame=CLIP_WORLD_FRAME_ID) # sensor → clip_world
84
+
85
+ # Get annotations transformed to clip-local world
86
+ anns_world = loader.annotations_for_sample(sample.token, frame=CLIP_WORLD_FRAME_ID)
78
87
 
79
- # Get annotations in ego frame (for training)
80
- anns_ego = loader.annotations_for_sample(sample.token, frame="ego")
88
+ # Or to a specific sensor's frame
89
+ anns_lidar = loader.annotations_for_sample(sample.token, frame="LIDAR_TOP")
81
90
 
82
- # Get the transform matrix directly
83
- T = loader.get_transform(sd, from_frame="sensor", to_frame="world")
91
+ # Get the transform matrix directly (sd is required when clip_world is involved)
92
+ T = loader.get_transform(loader.sensor_frame_id(sd), CLIP_WORLD_FRAME_ID, sd=sd)
84
93
  pts_world = T.apply(pts_sensor[:, :3]) # or use T.as_matrix for 4x4
85
94
  ```
86
95
 
File without changes
File without changes