x4d-devkit 0.1.0__tar.gz → 0.2.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 (42) hide show
  1. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/PKG-INFO +1 -1
  2. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/pyproject.toml +1 -1
  3. x4d_devkit-0.2.0/tests/test_coordinate_frames.py +450 -0
  4. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/core/loader.py +150 -8
  5. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit.egg-info/PKG-INFO +1 -1
  6. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit.egg-info/SOURCES.txt +1 -0
  7. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/LICENSE +0 -0
  8. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/README.md +0 -0
  9. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/setup.cfg +0 -0
  10. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/tests/test_detection_eval.py +0 -0
  11. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/tests/test_loader.py +0 -0
  12. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/tests/test_matching.py +0 -0
  13. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/tests/test_metrics.py +0 -0
  14. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/tests/test_models.py +0 -0
  15. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/tests/test_nuscenes_converter.py +0 -0
  16. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/tests/test_token.py +0 -0
  17. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/tests/test_transform.py +0 -0
  18. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/tests/test_transform_chain.py +0 -0
  19. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/tests/test_validation.py +0 -0
  20. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/__init__.py +0 -0
  21. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/cli.py +0 -0
  22. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/client/__init__.py +0 -0
  23. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/client/client.py +0 -0
  24. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/client/clips.py +0 -0
  25. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/client/projects.py +0 -0
  26. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/converters/__init__.py +0 -0
  27. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/converters/nuscenes.py +0 -0
  28. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/core/__init__.py +0 -0
  29. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/core/models.py +0 -0
  30. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/core/token.py +0 -0
  31. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/core/transform.py +0 -0
  32. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/eval/__init__.py +0 -0
  33. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/eval/detection.py +0 -0
  34. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/eval/matching.py +0 -0
  35. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/eval/metrics.py +0 -0
  36. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/validation/__init__.py +0 -0
  37. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/validation/report.py +0 -0
  38. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/validation/validator.py +0 -0
  39. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit.egg-info/dependency_links.txt +0 -0
  40. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit.egg-info/entry_points.txt +0 -0
  41. {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit.egg-info/requires.txt +0 -0
  42. {x4d_devkit-0.1.0 → x4d_devkit-0.2.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.1.0
3
+ Version: 0.2.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.1.0"
7
+ version = "0.2.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"
@@ -0,0 +1,450 @@
1
+ """Tests for ClipLoader coordinate frame support.
2
+
3
+ Unit tests use synthetic data (no nuScenes dependency).
4
+ Integration tests require nuScenes mini data.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+
11
+ import numpy as np
12
+ import pytest
13
+
14
+ 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
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Synthetic clip fixture
21
+ # ---------------------------------------------------------------------------
22
+
23
+ def _make_synthetic_clip(tmp_path: Path) -> Path:
24
+ """Create a minimal synthetic clip for unit testing."""
25
+ clip_dir = tmp_path / "synthetic_clip"
26
+ clip_dir.mkdir()
27
+
28
+ # -- meta.json --
29
+ (clip_dir / "meta.json").write_text(json.dumps({
30
+ "clip_id": "test-clip",
31
+ "schema_version": "0.4",
32
+ "session_id": "sess-001",
33
+ "vehicle_id": "veh-001",
34
+ "capture_time_utc": "2026-01-01T00:00:00Z",
35
+ "duration_s": 1.0,
36
+ "keyframe_count": 2,
37
+ "sweep_count": 0,
38
+ "sensors": {
39
+ "LIDAR_TOP": {
40
+ "modality": "lidar",
41
+ "point_format": {"fields": ["x", "y", "z", "intensity"]}
42
+ },
43
+ "CAM_FRONT": {
44
+ "modality": "camera",
45
+ "point_format": None
46
+ }
47
+ }
48
+ }))
49
+
50
+ # -- calibrated_sensor.json --
51
+ # LiDAR: translated 1m up (z=1), no rotation
52
+ # Camera: translated 0.5m forward (x=0.5), no rotation
53
+ (clip_dir / "calibrated_sensor.json").write_text(json.dumps([
54
+ {
55
+ "calibrated_sensor_token": "cs-lidar",
56
+ "channel": "LIDAR_TOP",
57
+ "translation": {"x": 0.0, "y": 0.0, "z": 1.0},
58
+ "rotation": {"qx": 0.0, "qy": 0.0, "qz": 0.0, "qw": 1.0},
59
+ "camera_intrinsic": None,
60
+ },
61
+ {
62
+ "calibrated_sensor_token": "cs-cam",
63
+ "channel": "CAM_FRONT",
64
+ "translation": {"x": 0.5, "y": 0.0, "z": 0.8},
65
+ "rotation": {"qx": 0.0, "qy": 0.0, "qz": 0.0, "qw": 1.0},
66
+ "camera_intrinsic": {"fx": 1000, "fy": 1000, "cx": 320, "cy": 240},
67
+ },
68
+ ]))
69
+
70
+ # -- ego_pose.json --
71
+ # Frame 0: ego at origin
72
+ # Frame 1: ego moved 10m forward (x=10)
73
+ (clip_dir / "ego_pose.json").write_text(json.dumps([
74
+ {
75
+ "ego_pose_token": "ep-0",
76
+ "timestamp_us": 1000000,
77
+ "translation": {"x": 0.0, "y": 0.0, "z": 0.0},
78
+ "rotation": {"qx": 0.0, "qy": 0.0, "qz": 0.0, "qw": 1.0},
79
+ },
80
+ {
81
+ "ego_pose_token": "ep-1",
82
+ "timestamp_us": 1500000,
83
+ "translation": {"x": 10.0, "y": 0.0, "z": 0.0},
84
+ "rotation": {"qx": 0.0, "qy": 0.0, "qz": 0.0, "qw": 1.0},
85
+ },
86
+ ]))
87
+
88
+ # -- sample.json --
89
+ (clip_dir / "sample.json").write_text(json.dumps([
90
+ {
91
+ "sample_token": "s-0",
92
+ "timestamp_us": 1000000,
93
+ "is_keyframe": True,
94
+ "prev": None,
95
+ "next": "s-1",
96
+ },
97
+ {
98
+ "sample_token": "s-1",
99
+ "timestamp_us": 1500000,
100
+ "is_keyframe": True,
101
+ "prev": "s-0",
102
+ "next": None,
103
+ },
104
+ ]))
105
+
106
+ # -- sample_data.json --
107
+ lidar_dir = clip_dir / "LIDAR_TOP"
108
+ lidar_dir.mkdir()
109
+
110
+ (clip_dir / "sample_data.json").write_text(json.dumps([
111
+ {
112
+ "sample_data_token": "sd-lidar-0",
113
+ "sample_token": "s-0",
114
+ "channel": "LIDAR_TOP",
115
+ "timestamp_us": 1000000,
116
+ "file_path": "LIDAR_TOP/frame0.bin",
117
+ "ego_pose_token": "ep-0",
118
+ "calibrated_sensor_token": "cs-lidar",
119
+ "is_keyframe": True,
120
+ "width": None,
121
+ "height": None,
122
+ "prev": None,
123
+ "next": "sd-lidar-1",
124
+ },
125
+ {
126
+ "sample_data_token": "sd-lidar-1",
127
+ "sample_token": "s-1",
128
+ "channel": "LIDAR_TOP",
129
+ "timestamp_us": 1500000,
130
+ "file_path": "LIDAR_TOP/frame1.bin",
131
+ "ego_pose_token": "ep-1",
132
+ "calibrated_sensor_token": "cs-lidar",
133
+ "is_keyframe": True,
134
+ "width": None,
135
+ "height": None,
136
+ "prev": "sd-lidar-0",
137
+ "next": None,
138
+ },
139
+ ]))
140
+
141
+ # -- point cloud binary files --
142
+ # 4 points at known positions: (1,0,0), (0,1,0), (0,0,1), (5,5,0)
143
+ # 4 fields: x, y, z, intensity
144
+ pts = np.array([
145
+ [1.0, 0.0, 0.0, 0.5],
146
+ [0.0, 1.0, 0.0, 0.5],
147
+ [0.0, 0.0, 1.0, 0.5],
148
+ [5.0, 5.0, 0.0, 0.5],
149
+ ], dtype=np.float32)
150
+ pts.tofile(str(lidar_dir / "frame0.bin"))
151
+ pts.tofile(str(lidar_dir / "frame1.bin"))
152
+
153
+ # -- annotation.json --
154
+ (clip_dir / "annotation.json").write_text(json.dumps([
155
+ {
156
+ "annotation_token": "ann-0",
157
+ "sample_token": "s-0",
158
+ "instance_token": "inst-0",
159
+ "category": "car",
160
+ "bbox_3d": {
161
+ "translation": {"x": 5.0, "y": 3.0, "z": 0.0},
162
+ "size": {"length": 4.5, "width": 2.0, "height": 1.5},
163
+ "rotation": {"qx": 0.0, "qy": 0.0, "qz": 0.0, "qw": 1.0},
164
+ },
165
+ "num_lidar_pts": 100,
166
+ "visibility": "4",
167
+ "velocity": {"vx": 5.0, "vy": 0.0, "vz": 0.0},
168
+ "attributes": [],
169
+ "prev": None,
170
+ "next": "ann-1",
171
+ "score": None,
172
+ },
173
+ {
174
+ "annotation_token": "ann-1",
175
+ "sample_token": "s-1",
176
+ "instance_token": "inst-0",
177
+ "category": "car",
178
+ "bbox_3d": {
179
+ "translation": {"x": 10.0, "y": 3.0, "z": 0.0},
180
+ "size": {"length": 4.5, "width": 2.0, "height": 1.5},
181
+ "rotation": {"qx": 0.0, "qy": 0.0, "qz": 0.0, "qw": 1.0},
182
+ },
183
+ "num_lidar_pts": 90,
184
+ "visibility": "4",
185
+ "velocity": {"vx": 5.0, "vy": 0.0, "vz": 0.0},
186
+ "attributes": [],
187
+ "prev": "ann-0",
188
+ "next": None,
189
+ "score": None,
190
+ },
191
+ ]))
192
+
193
+ # -- instance.json --
194
+ (clip_dir / "instance.json").write_text(json.dumps([
195
+ {
196
+ "instance_token": "inst-0",
197
+ "category": "car",
198
+ "num_annotations": 2,
199
+ "first_annotation_token": "ann-0",
200
+ "last_annotation_token": "ann-1",
201
+ },
202
+ ]))
203
+
204
+ return clip_dir
205
+
206
+
207
+ @pytest.fixture(scope="module")
208
+ def synthetic_clip(tmp_path_factory):
209
+ return _make_synthetic_clip(tmp_path_factory.mktemp("synth"))
210
+
211
+
212
+ @pytest.fixture(scope="module")
213
+ def loader(synthetic_clip):
214
+ return ClipLoader(synthetic_clip)
215
+
216
+
217
+ # ---------------------------------------------------------------------------
218
+ # Unit tests: load_point_cloud with frame parameter
219
+ # ---------------------------------------------------------------------------
220
+
221
+ class TestLoadPointCloudFrames:
222
+ def test_sensor_frame_default(self, loader):
223
+ """Default frame should return raw sensor coordinates."""
224
+ sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
225
+ pts = loader.load_point_cloud(sd)
226
+ assert pts.shape == (4, 4)
227
+ np.testing.assert_allclose(pts[0, :3], [1.0, 0.0, 0.0], atol=1e-6)
228
+
229
+ def test_sensor_frame_explicit(self, loader):
230
+ """Explicit frame='sensor' should match default."""
231
+ sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
232
+ pts_default = loader.load_point_cloud(sd)
233
+ pts_sensor = loader.load_point_cloud(sd, frame="sensor")
234
+ np.testing.assert_array_equal(pts_default, pts_sensor)
235
+
236
+ def test_ego_frame(self, loader):
237
+ """frame='ego' applies sensor-to-ego transform (z+1 for LIDAR_TOP)."""
238
+ sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
239
+ pts = loader.load_point_cloud(sd, frame="ego")
240
+ # LiDAR is at z=1 in ego frame, so sensor point (1,0,0) -> ego (1,0,1)
241
+ np.testing.assert_allclose(pts[0, :3], [1.0, 0.0, 1.0], atol=1e-5)
242
+ # sensor (0,0,1) -> ego (0,0,2)
243
+ np.testing.assert_allclose(pts[2, :3], [0.0, 0.0, 2.0], atol=1e-5)
244
+
245
+ def test_world_frame_first_ego(self, loader):
246
+ """frame='world' for first frame (ego at origin): same as ego frame."""
247
+ sd = loader.sample_data_for_channel("LIDAR_TOP")[0] # ep-0 at origin
248
+ pts_ego = loader.load_point_cloud(sd, frame="ego")
249
+ pts_world = loader.load_point_cloud(sd, frame="world")
250
+ np.testing.assert_allclose(pts_world[:, :3], pts_ego[:, :3], atol=1e-5)
251
+
252
+ def test_world_frame_second_ego(self, loader):
253
+ """frame='world' for second frame (ego at x=10): adds ego offset."""
254
+ sd = loader.sample_data_for_channel("LIDAR_TOP")[1] # ep-1 at x=10
255
+ pts = loader.load_point_cloud(sd, frame="world")
256
+ # sensor (1,0,0) -> ego (1,0,1) -> world (11,0,1)
257
+ np.testing.assert_allclose(pts[0, :3], [11.0, 0.0, 1.0], atol=1e-5)
258
+
259
+ def test_intensity_preserved(self, loader):
260
+ """Non-xyz columns should not be modified."""
261
+ sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
262
+ pts = loader.load_point_cloud(sd, frame="world")
263
+ np.testing.assert_allclose(pts[:, 3], 0.5, atol=1e-6)
264
+
265
+ def test_invalid_frame_raises(self, loader):
266
+ sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
267
+ with pytest.raises(ValueError, match="Invalid frame"):
268
+ loader.load_point_cloud(sd, frame="invalid")
269
+
270
+
271
+ # ---------------------------------------------------------------------------
272
+ # Unit tests: annotations_for_sample with frame parameter
273
+ # ---------------------------------------------------------------------------
274
+
275
+ class TestAnnotationsForSampleFrames:
276
+ def test_world_frame_default(self, loader):
277
+ """Default frame should return annotations in world coordinates."""
278
+ anns = loader.annotations_for_sample("s-0")
279
+ assert len(anns) == 1
280
+ np.testing.assert_allclose(anns[0].translation, [5.0, 3.0, 0.0])
281
+
282
+ def test_world_frame_explicit(self, loader):
283
+ """Explicit frame='world' should match default."""
284
+ anns_default = loader.annotations_for_sample("s-0")
285
+ anns_world = loader.annotations_for_sample("s-0", frame="world")
286
+ assert len(anns_default) == len(anns_world)
287
+ np.testing.assert_allclose(anns_default[0].translation, anns_world[0].translation)
288
+
289
+ def test_ego_frame_first_sample(self, loader):
290
+ """frame='ego' for first sample (ego at origin): same as world."""
291
+ anns = loader.annotations_for_sample("s-0", frame="ego")
292
+ # ego at origin -> world-to-ego is identity
293
+ np.testing.assert_allclose(anns[0].translation, [5.0, 3.0, 0.0], atol=1e-5)
294
+
295
+ def test_ego_frame_second_sample(self, loader):
296
+ """frame='ego' for second sample (ego at x=10): subtracts ego offset."""
297
+ anns = loader.annotations_for_sample("s-1", frame="ego")
298
+ # annotation at world (10,3,0), ego at (10,0,0) -> ego frame (0,3,0)
299
+ np.testing.assert_allclose(anns[0].translation, [0.0, 3.0, 0.0], atol=1e-5)
300
+
301
+ def test_ego_frame_velocity_rotated(self, loader):
302
+ """Velocity should be rotated (not translated) to ego frame."""
303
+ anns = loader.annotations_for_sample("s-0", frame="ego")
304
+ # ego rotation is identity, so velocity unchanged
305
+ np.testing.assert_allclose(anns[0].velocity, [5.0, 0.0, 0.0], atol=1e-5)
306
+
307
+ def test_ego_frame_preserves_other_fields(self, loader):
308
+ """Non-pose fields should be preserved."""
309
+ anns_world = loader.annotations_for_sample("s-0", frame="world")
310
+ anns_ego = loader.annotations_for_sample("s-0", frame="ego")
311
+ assert anns_ego[0].token == anns_world[0].token
312
+ assert anns_ego[0].category == anns_world[0].category
313
+ np.testing.assert_allclose(anns_ego[0].size, anns_world[0].size)
314
+ assert anns_ego[0].num_lidar_pts == anns_world[0].num_lidar_pts
315
+
316
+ def test_ego_frame_returns_new_objects(self, loader):
317
+ """Transformed annotations should be new objects, not mutated originals."""
318
+ anns_world = loader.annotations_for_sample("s-1", frame="world")
319
+ anns_ego = loader.annotations_for_sample("s-1", frame="ego")
320
+ # Originals should be unmodified
321
+ np.testing.assert_allclose(anns_world[0].translation, [10.0, 3.0, 0.0])
322
+ # Ego should be different
323
+ assert not np.allclose(anns_ego[0].translation, anns_world[0].translation, atol=0.1)
324
+
325
+ def test_sensor_frame_raises(self, loader):
326
+ with pytest.raises(ValueError, match="sensor frame"):
327
+ loader.annotations_for_sample("s-0", frame="sensor")
328
+
329
+ def test_invalid_frame_raises(self, loader):
330
+ with pytest.raises(ValueError, match="Invalid frame"):
331
+ loader.annotations_for_sample("s-0", frame="invalid")
332
+
333
+ def test_empty_sample_returns_empty(self, loader):
334
+ """Non-existent sample returns empty list regardless of frame."""
335
+ assert loader.annotations_for_sample("nonexistent", frame="ego") == []
336
+
337
+
338
+ # ---------------------------------------------------------------------------
339
+ # Unit tests: get_transform
340
+ # ---------------------------------------------------------------------------
341
+
342
+ class TestGetTransform:
343
+ def test_identity_same_frame(self, loader):
344
+ sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
345
+ T = loader.get_transform(sd, from_frame="sensor", to_frame="sensor")
346
+ np.testing.assert_allclose(T.as_matrix, np.eye(4), atol=1e-10)
347
+
348
+ def test_sensor_to_ego(self, loader):
349
+ sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
350
+ T = loader.get_transform(sd, from_frame="sensor", to_frame="ego")
351
+ # LiDAR at z=1
352
+ np.testing.assert_allclose(T.translation, [0.0, 0.0, 1.0], atol=1e-10)
353
+
354
+ def test_sensor_to_world(self, loader):
355
+ sd = loader.sample_data_for_channel("LIDAR_TOP")[1] # ego at x=10
356
+ T = loader.get_transform(sd, from_frame="sensor", to_frame="world")
357
+ # sensor to ego (z+1) then ego to world (x+10)
358
+ np.testing.assert_allclose(T.translation, [10.0, 0.0, 1.0], atol=1e-10)
359
+
360
+ def test_ego_to_world(self, loader):
361
+ sd = loader.sample_data_for_channel("LIDAR_TOP")[1]
362
+ T = loader.get_transform(sd, from_frame="ego", to_frame="world")
363
+ np.testing.assert_allclose(T.translation, [10.0, 0.0, 0.0], atol=1e-10)
364
+
365
+ def test_round_trip(self, loader):
366
+ """sensor→world→sensor should be identity."""
367
+ sd = loader.sample_data_for_channel("LIDAR_TOP")[1]
368
+ T_fwd = loader.get_transform(sd, from_frame="sensor", to_frame="world")
369
+ T_rev = loader.get_transform(sd, from_frame="world", to_frame="sensor")
370
+ T_round = T_fwd @ T_rev
371
+ np.testing.assert_allclose(T_round.as_matrix, np.eye(4), atol=1e-10)
372
+
373
+ def test_all_six_combinations(self, loader):
374
+ """All from/to combinations should work without error."""
375
+ sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
376
+ frames = ["sensor", "ego", "world"]
377
+ for f in frames:
378
+ for t in frames:
379
+ T = loader.get_transform(sd, from_frame=f, to_frame=t)
380
+ assert T.as_matrix.shape == (4, 4)
381
+
382
+ def test_invalid_from_frame_raises(self, loader):
383
+ sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
384
+ with pytest.raises(ValueError, match="Invalid from_frame"):
385
+ loader.get_transform(sd, from_frame="bad", to_frame="ego")
386
+
387
+ def test_invalid_to_frame_raises(self, loader):
388
+ sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
389
+ with pytest.raises(ValueError, match="Invalid to_frame"):
390
+ loader.get_transform(sd, from_frame="sensor", to_frame="bad")
391
+
392
+ def test_consistency_with_load_point_cloud(self, loader):
393
+ """get_transform result should match load_point_cloud frame output."""
394
+ sd = loader.sample_data_for_channel("LIDAR_TOP")[1]
395
+ pts_sensor = loader.load_point_cloud(sd, frame="sensor")
396
+ pts_world = loader.load_point_cloud(sd, frame="world")
397
+
398
+ T = loader.get_transform(sd, from_frame="sensor", to_frame="world")
399
+ pts_manual = T.apply(pts_sensor[:, :3].astype(np.float64)).astype(np.float32)
400
+ np.testing.assert_allclose(pts_world[:, :3], pts_manual, atol=1e-5)
401
+
402
+
403
+ # ---------------------------------------------------------------------------
404
+ # Integration tests (nuScenes)
405
+ # ---------------------------------------------------------------------------
406
+
407
+ NUSCENES_DATAROOT = "/data/PublicDatasets/nuscenes"
408
+ NUSCENES_AVAILABLE = os.path.isdir(os.path.join(NUSCENES_DATAROOT, "v1.0-mini"))
409
+
410
+
411
+ @pytest.fixture(scope="module")
412
+ def nuscenes_loader(tmp_path_factory):
413
+ if not NUSCENES_AVAILABLE:
414
+ pytest.skip("nuScenes data not found")
415
+ from x4d_devkit.converters.nuscenes import NuScenesConverter
416
+
417
+ out = tmp_path_factory.mktemp("frames_test")
418
+ conv = NuScenesConverter(
419
+ dataroot=NUSCENES_DATAROOT, version="v1.0-mini", output_dir=str(out),
420
+ )
421
+ scene = conv.nusc.scene[0]
422
+ clip_id = conv.convert_scene(scene["token"])
423
+ return ClipLoader(out / "clips" / clip_id)
424
+
425
+
426
+ @pytest.mark.skipif(not NUSCENES_AVAILABLE, reason="nuScenes data not found")
427
+ class TestCoordinateFramesIntegration:
428
+ def test_point_cloud_round_trip(self, nuscenes_loader):
429
+ """sensor→world→sensor should recover original points."""
430
+ sd = next(
431
+ s for s in nuscenes_loader.sample_data_for_channel("LIDAR_TOP") if s.is_keyframe
432
+ )
433
+ pts_sensor = nuscenes_loader.load_point_cloud(sd, frame="sensor")
434
+ T = nuscenes_loader.get_transform(sd, from_frame="world", to_frame="sensor")
435
+ pts_world = nuscenes_loader.load_point_cloud(sd, frame="world")
436
+ recovered = T.apply(pts_world[:, :3].astype(np.float64)).astype(np.float32)
437
+ np.testing.assert_allclose(recovered, pts_sensor[:, :3], atol=1e-4)
438
+
439
+ def test_ego_annotations_near_ego(self, nuscenes_loader):
440
+ """Ego-frame annotations should be centered around the ego vehicle."""
441
+ sample = nuscenes_loader.samples[0]
442
+ anns_world = nuscenes_loader.annotations_for_sample(sample.token, frame="world")
443
+ anns_ego = nuscenes_loader.annotations_for_sample(sample.token, frame="ego")
444
+ if not anns_world:
445
+ pytest.skip("No annotations in first sample")
446
+
447
+ # In ego frame, nearby objects should be within reasonable range
448
+ for ann in anns_ego:
449
+ dist = np.linalg.norm(ann.translation[:2]) # xy distance from ego
450
+ assert dist < 200, f"Annotation too far in ego frame: {dist}m"
@@ -3,8 +3,9 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ from dataclasses import replace
6
7
  from pathlib import Path
7
- from typing import Any
8
+ from typing import Any, Literal
8
9
 
9
10
  import numpy as np
10
11
 
@@ -17,6 +18,10 @@ from x4d_devkit.core.models import (
17
18
  Annotation,
18
19
  Instance,
19
20
  )
21
+ from x4d_devkit.core.transform import Transform, TransformChain, rotation_matrix_to_quat
22
+
23
+ FrameType = Literal["sensor", "ego", "world"]
24
+ _VALID_FRAMES = {"sensor", "ego", "world"}
20
25
 
21
26
 
22
27
  class ClipLoader:
@@ -237,23 +242,160 @@ class ClipLoader:
237
242
  """All sample_data records for a given sample (all channels at that timestamp)."""
238
243
  return self._sd_by_sample.get(sample_token, [])
239
244
 
240
- def annotations_for_sample(self, sample_token: str) -> list[Annotation]:
241
- """All annotations for a given sample."""
242
- return self._ann_by_sample.get(sample_token, [])
243
-
244
- def load_point_cloud(self, sd: SampleData) -> np.ndarray:
245
+ def annotations_for_sample(
246
+ self,
247
+ sample_token: str,
248
+ frame: FrameType = "world",
249
+ ) -> list[Annotation]:
250
+ """All annotations for a given sample.
251
+
252
+ Args:
253
+ sample_token: The sample token.
254
+ frame: Target coordinate frame.
255
+ - "world" (default): native annotation frame, no transform.
256
+ - "ego": transform from world to ego frame using the ego_pose
257
+ of the LiDAR sample_data for this sample (or first sample_data
258
+ if no LiDAR).
259
+
260
+ Returns:
261
+ List of Annotation objects in the requested frame.
262
+ """
263
+ if frame not in _VALID_FRAMES:
264
+ raise ValueError(f"Invalid frame '{frame}', must be one of {_VALID_FRAMES}")
265
+ if frame == "sensor":
266
+ raise ValueError("Annotations cannot be transformed to sensor frame")
267
+
268
+ anns = self._ann_by_sample.get(sample_token, [])
269
+ if frame == "world" or not anns:
270
+ return anns
271
+
272
+ # frame == "ego": need ego_pose for this sample
273
+ ego_from_world = self._get_ego_from_world_for_sample(sample_token)
274
+ return [self._transform_annotation(a, ego_from_world) for a in anns]
275
+
276
+ def load_point_cloud(
277
+ self,
278
+ sd: SampleData,
279
+ frame: FrameType = "sensor",
280
+ ) -> np.ndarray:
245
281
  """Load point cloud binary file as (N, C) float32 array.
246
282
 
247
- Uses meta.sensors[channel].point_format to determine stride.
283
+ Args:
284
+ sd: The SampleData record for the point cloud.
285
+ frame: Target coordinate frame.
286
+ - "sensor" (default): raw sensor frame.
287
+ - "ego": sensor → ego via calibrated_sensor extrinsics.
288
+ - "world": sensor → ego → world via extrinsics + ego_pose.
289
+
290
+ Returns:
291
+ Point cloud as (N, C) float32 array with xyz in the requested frame.
248
292
  """
293
+ if frame not in _VALID_FRAMES:
294
+ raise ValueError(f"Invalid frame '{frame}', must be one of {_VALID_FRAMES}")
295
+
249
296
  sensor_info = self._meta.sensors[sd.channel]
250
297
  pf = sensor_info["point_format"]
251
298
  num_fields = len(pf["fields"])
252
299
 
253
300
  path = self._clip_dir / sd.file_path
254
301
  raw = np.fromfile(str(path), dtype=np.float32)
255
- return raw.reshape(-1, num_fields)
302
+ pts = raw.reshape(-1, num_fields)
303
+
304
+ if frame == "sensor":
305
+ return pts
306
+
307
+ T = self.get_transform(sd, from_frame="sensor", to_frame=frame)
308
+ pts[:, :3] = T.apply(pts[:, :3].astype(np.float64)).astype(np.float32)
309
+ return pts
310
+
311
+ def get_transform(
312
+ self,
313
+ sd: SampleData,
314
+ from_frame: FrameType = "sensor",
315
+ to_frame: FrameType = "world",
316
+ ) -> Transform:
317
+ """Get a Transform between coordinate frames for a given sample_data.
318
+
319
+ Args:
320
+ sd: The SampleData record (provides channel and ego_pose_token).
321
+ from_frame: Source frame ("sensor", "ego", or "world").
322
+ to_frame: Target frame ("sensor", "ego", or "world").
323
+
324
+ Returns:
325
+ Transform object. Use T.apply(points) or T.as_matrix for the 4x4 matrix.
326
+ """
327
+ if from_frame not in _VALID_FRAMES:
328
+ raise ValueError(f"Invalid from_frame '{from_frame}', must be one of {_VALID_FRAMES}")
329
+ if to_frame not in _VALID_FRAMES:
330
+ raise ValueError(f"Invalid to_frame '{to_frame}', must be one of {_VALID_FRAMES}")
331
+
332
+ if from_frame == to_frame:
333
+ return Transform.identity()
334
+
335
+ chain = self._get_transform_chain()
336
+ ego_from_sensor = chain.get_ego_from_sensor(sd.channel)
337
+ world_from_ego = chain.get_world_from_ego(sd.ego_pose_token)
338
+
339
+ if from_frame == "sensor" and to_frame == "ego":
340
+ return ego_from_sensor
341
+ elif from_frame == "sensor" and to_frame == "world":
342
+ return world_from_ego @ ego_from_sensor
343
+ elif from_frame == "ego" and to_frame == "world":
344
+ return world_from_ego
345
+ elif from_frame == "ego" and to_frame == "sensor":
346
+ return ego_from_sensor.inverse
347
+ elif from_frame == "world" and to_frame == "ego":
348
+ return world_from_ego.inverse
349
+ else: # world -> sensor
350
+ return (world_from_ego @ ego_from_sensor).inverse
256
351
 
257
352
  def load_image_path(self, sd: SampleData) -> Path:
258
353
  """Return the absolute path to the image file (does not load pixels)."""
259
354
  return self._clip_dir / sd.file_path
355
+
356
+ # --- Private helpers ---
357
+
358
+ def _get_transform_chain(self) -> TransformChain:
359
+ """Lazily initialize and return a TransformChain."""
360
+ if not hasattr(self, "_transform_chain"):
361
+ self._transform_chain = TransformChain(self)
362
+ return self._transform_chain
363
+
364
+ def _get_ego_from_world_for_sample(self, sample_token: str) -> Transform:
365
+ """Get the inverse ego_pose transform for a sample.
366
+
367
+ Uses the ego_pose from the LiDAR sample_data, or the first sample_data
368
+ if no LiDAR channel exists.
369
+ """
370
+ sds = self._sd_by_sample.get(sample_token, [])
371
+ if not sds:
372
+ raise ValueError(f"No sample_data found for sample {sample_token}")
373
+
374
+ # Prefer LiDAR sample_data for the ego_pose
375
+ cs_map = {cs.channel: cs for cs in self._calibrated_sensors}
376
+ sd = next(
377
+ (s for s in sds if cs_map.get(s.channel) and cs_map[s.channel].modality == "lidar"),
378
+ sds[0],
379
+ )
380
+ chain = self._get_transform_chain()
381
+ return chain.get_world_from_ego(sd.ego_pose_token).inverse
382
+
383
+ @staticmethod
384
+ def _transform_annotation(ann: Annotation, T: Transform) -> Annotation:
385
+ """Apply a rigid transform to an annotation's pose, returning a new Annotation."""
386
+ new_translation = T.apply(ann.translation)
387
+ new_rotation_mat = T.rotation @ Transform.from_quat(
388
+ [0, 0, 0], ann.rotation
389
+ ).rotation
390
+ new_quat = rotation_matrix_to_quat(new_rotation_mat)
391
+
392
+ new_velocity = None
393
+ if ann.velocity is not None:
394
+ new_velocity = T.rotation @ ann.velocity
395
+
396
+ return replace(
397
+ ann,
398
+ translation=new_translation,
399
+ rotation=np.array(new_quat),
400
+ velocity=new_velocity,
401
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: x4d-devkit
3
- Version: 0.1.0
3
+ Version: 0.2.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
@@ -1,6 +1,7 @@
1
1
  LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
+ tests/test_coordinate_frames.py
4
5
  tests/test_detection_eval.py
5
6
  tests/test_loader.py
6
7
  tests/test_matching.py
File without changes
File without changes
File without changes
File without changes