x4d-devkit 0.9.0__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.9.0/x4d_devkit.egg-info → x4d_devkit-0.12.0}/PKG-INFO +18 -9
  2. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/README.md +17 -8
  3. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/pyproject.toml +1 -1
  4. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/tests/test_coordinate_frames.py +174 -131
  5. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/tests/test_v06_schema.py +1 -1
  6. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/converters/nuscenes.py +89 -28
  7. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/core/loader.py +147 -121
  8. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/core/models.py +11 -3
  9. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/core/transform.py +89 -12
  10. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/validation/validator.py +160 -21
  11. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0/x4d_devkit.egg-info}/PKG-INFO +18 -9
  12. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/LICENSE +0 -0
  13. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/setup.cfg +0 -0
  14. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/tests/test_box.py +0 -0
  15. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/tests/test_detection_eval.py +0 -0
  16. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/tests/test_evaluate.py +0 -0
  17. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/tests/test_fusion.py +0 -0
  18. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/tests/test_loader.py +0 -0
  19. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/tests/test_matching.py +0 -0
  20. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/tests/test_metrics.py +0 -0
  21. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/tests/test_models.py +0 -0
  22. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/tests/test_nuscenes_converter.py +0 -0
  23. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/tests/test_token.py +0 -0
  24. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/tests/test_transform.py +0 -0
  25. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/tests/test_transform_chain.py +0 -0
  26. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/tests/test_v06_api_additions.py +0 -0
  27. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/tests/test_validation.py +0 -0
  28. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/__init__.py +0 -0
  29. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/cli.py +0 -0
  30. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/client/__init__.py +0 -0
  31. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/client/checkpoints.py +0 -0
  32. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/client/client.py +0 -0
  33. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/client/clips.py +0 -0
  34. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/client/projects.py +0 -0
  35. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/client/test_sets.py +0 -0
  36. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/converters/__init__.py +0 -0
  37. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/core/__init__.py +0 -0
  38. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/core/token.py +0 -0
  39. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/eval/__init__.py +0 -0
  40. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/eval/api.py +0 -0
  41. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/eval/box.py +0 -0
  42. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/eval/detection.py +0 -0
  43. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/eval/matching.py +0 -0
  44. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/eval/metrics.py +0 -0
  45. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/fusion.py +0 -0
  46. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/validation/__init__.py +0 -0
  47. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/validation/report.py +0 -0
  48. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit.egg-info/SOURCES.txt +0 -0
  49. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit.egg-info/dependency_links.txt +0 -0
  50. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit.egg-info/entry_points.txt +0 -0
  51. {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit.egg-info/requires.txt +0 -0
  52. {x4d_devkit-0.9.0 → 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.9.0
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.9.0"
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