x4d-devkit 0.1.0__tar.gz → 0.2.1__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 (46) hide show
  1. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/PKG-INFO +27 -7
  2. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/README.md +26 -6
  3. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/pyproject.toml +1 -1
  4. x4d_devkit-0.2.1/tests/test_coordinate_frames.py +450 -0
  5. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit/__init__.py +2 -0
  6. x4d_devkit-0.2.1/x4d_devkit/client/__init__.py +13 -0
  7. x4d_devkit-0.2.1/x4d_devkit/client/checkpoints.py +64 -0
  8. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit/client/client.py +23 -6
  9. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit/client/clips.py +52 -8
  10. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit/client/projects.py +11 -0
  11. x4d_devkit-0.2.1/x4d_devkit/client/test_sets.py +63 -0
  12. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit/core/loader.py +214 -13
  13. x4d_devkit-0.2.1/x4d_devkit/core/models.py +215 -0
  14. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit/core/transform.py +111 -14
  15. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit/eval/detection.py +40 -1
  16. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit/validation/report.py +20 -2
  17. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit/validation/validator.py +25 -1
  18. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit.egg-info/PKG-INFO +27 -7
  19. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit.egg-info/SOURCES.txt +3 -0
  20. x4d_devkit-0.1.0/x4d_devkit/client/__init__.py +0 -3
  21. x4d_devkit-0.1.0/x4d_devkit/core/models.py +0 -106
  22. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/LICENSE +0 -0
  23. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/setup.cfg +0 -0
  24. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/tests/test_detection_eval.py +0 -0
  25. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/tests/test_loader.py +0 -0
  26. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/tests/test_matching.py +0 -0
  27. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/tests/test_metrics.py +0 -0
  28. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/tests/test_models.py +0 -0
  29. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/tests/test_nuscenes_converter.py +0 -0
  30. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/tests/test_token.py +0 -0
  31. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/tests/test_transform.py +0 -0
  32. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/tests/test_transform_chain.py +0 -0
  33. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/tests/test_validation.py +0 -0
  34. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit/cli.py +0 -0
  35. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit/converters/__init__.py +0 -0
  36. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit/converters/nuscenes.py +0 -0
  37. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit/core/__init__.py +0 -0
  38. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit/core/token.py +0 -0
  39. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit/eval/__init__.py +0 -0
  40. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit/eval/matching.py +0 -0
  41. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit/eval/metrics.py +0 -0
  42. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit/validation/__init__.py +0 -0
  43. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit.egg-info/dependency_links.txt +0 -0
  44. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit.egg-info/entry_points.txt +0 -0
  45. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit.egg-info/requires.txt +0 -0
  46. {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/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.1
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
@@ -57,13 +57,33 @@ pip install x4d-devkit[client]
57
57
  ```python
58
58
  from x4d_devkit import ClipLoader
59
59
 
60
- clip = ClipLoader("/path/to/clip")
61
- print(clip.meta)
60
+ loader = ClipLoader("/path/to/clip")
61
+ print(loader.meta)
62
62
 
63
- for sample in clip.samples:
64
- # Access sensor data
65
- for sd in clip.sample_data_by_sample(sample.token):
66
- print(sd.channel, sd.filename)
63
+ for sample in loader.samples:
64
+ for sd in loader.sample_data_for_sample(sample.token):
65
+ print(sd.channel, sd.file_path)
66
+ ```
67
+
68
+ ### Coordinate frame transforms
69
+
70
+ Point clouds and annotations can be loaded in different coordinate frames:
71
+
72
+ ```python
73
+ loader = ClipLoader("/path/to/clip")
74
+ sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
75
+
76
+ # Load point cloud in different frames
77
+ pts_sensor = loader.load_point_cloud(sd, frame="sensor") # raw (default)
78
+ pts_ego = loader.load_point_cloud(sd, frame="ego") # sensor → ego
79
+ pts_world = loader.load_point_cloud(sd, frame="world") # sensor → world
80
+
81
+ # Get annotations in ego frame (for training)
82
+ anns_ego = loader.annotations_for_sample(sample.token, frame="ego")
83
+
84
+ # Get the transform matrix directly
85
+ T = loader.get_transform(sd, from_frame="sensor", to_frame="world")
86
+ pts_world = T.apply(pts_sensor[:, :3]) # or use T.as_matrix for 4x4
67
87
  ```
68
88
 
69
89
  ### Validate a clip
@@ -25,13 +25,33 @@ pip install x4d-devkit[client]
25
25
  ```python
26
26
  from x4d_devkit import ClipLoader
27
27
 
28
- clip = ClipLoader("/path/to/clip")
29
- print(clip.meta)
28
+ loader = ClipLoader("/path/to/clip")
29
+ print(loader.meta)
30
30
 
31
- for sample in clip.samples:
32
- # Access sensor data
33
- for sd in clip.sample_data_by_sample(sample.token):
34
- print(sd.channel, sd.filename)
31
+ for sample in loader.samples:
32
+ for sd in loader.sample_data_for_sample(sample.token):
33
+ print(sd.channel, sd.file_path)
34
+ ```
35
+
36
+ ### Coordinate frame transforms
37
+
38
+ Point clouds and annotations can be loaded in different coordinate frames:
39
+
40
+ ```python
41
+ loader = ClipLoader("/path/to/clip")
42
+ sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
43
+
44
+ # 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
48
+
49
+ # Get annotations in ego frame (for training)
50
+ anns_ego = loader.annotations_for_sample(sample.token, frame="ego")
51
+
52
+ # Get the transform matrix directly
53
+ T = loader.get_transform(sd, from_frame="sensor", to_frame="world")
54
+ pts_world = T.apply(pts_sensor[:, :3]) # or use T.as_matrix for 4x4
35
55
  ```
36
56
 
37
57
  ### Validate a clip
@@ -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.1"
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"
@@ -1,5 +1,6 @@
1
1
  """X-4D dataset format SDK."""
2
2
 
3
+ from x4d_devkit.client import X4DClient
3
4
  from x4d_devkit.core import (
4
5
  Annotation,
5
6
  CalibratedSensor,
@@ -36,6 +37,7 @@ __all__ = [
36
37
  "Transform",
37
38
  "TransformChain",
38
39
  "ValidationReport",
40
+ "X4DClient",
39
41
  "generate_token",
40
42
  "nusc_detection_config",
41
43
  "validate_clip",
@@ -0,0 +1,13 @@
1
+ from x4d_devkit.client.checkpoints import CheckpointsAPI
2
+ from x4d_devkit.client.client import X4DClient
3
+ from x4d_devkit.client.clips import ClipsAPI
4
+ from x4d_devkit.client.projects import ProjectsAPI
5
+ from x4d_devkit.client.test_sets import TestSetsAPI
6
+
7
+ __all__ = [
8
+ "CheckpointsAPI",
9
+ "ClipsAPI",
10
+ "ProjectsAPI",
11
+ "TestSetsAPI",
12
+ "X4DClient",
13
+ ]
@@ -0,0 +1,64 @@
1
+ """Checkpoints API wrapper — training project registers its trained models
2
+ so the platform's Model Compare view can run inference on them.
3
+
4
+ This is the primary integration point between a training project and the
5
+ platform: after every epoch the training CLI calls ``register(...)`` and
6
+ the checkpoint immediately shows up in Compare's dropdown.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ if TYPE_CHECKING:
13
+ from x4d_devkit.client.client import X4DClient
14
+
15
+
16
+ class CheckpointsAPI:
17
+ def __init__(self, client: "X4DClient"):
18
+ self._client = client
19
+
20
+ def register(
21
+ self,
22
+ project_id: int,
23
+ name: str,
24
+ service_id: str,
25
+ ckpt_uri: str,
26
+ metadata: dict[str, Any] | None = None,
27
+ ) -> dict:
28
+ """Register a trained checkpoint for inference.
29
+
30
+ Idempotent on (project_id, service_id, ckpt_uri) — re-calling with
31
+ the same arguments updates name/metadata instead of creating a dup.
32
+
33
+ Args:
34
+ project_id: X-4D project the checkpoint belongs to.
35
+ name: Human-readable name, e.g. ``"exp_hg1_v1 · epoch 5"``.
36
+ service_id: The ``service_id`` of the training/inference service
37
+ that owns the checkpoint file (must be registered with the
38
+ platform, otherwise inference dispatch will 404).
39
+ ckpt_uri: Local path to the .pth file *on the service container*.
40
+ Remote/S3 URIs are not yet supported.
41
+ metadata: Arbitrary JSONB bag. By convention keys used by the
42
+ inference backend are ``cfg_file``, ``epoch``, ``is_best``,
43
+ ``training_task_id``.
44
+ """
45
+ return self._client.post(
46
+ "/checkpoints",
47
+ json={
48
+ "project_id": project_id,
49
+ "name": name,
50
+ "service_id": service_id,
51
+ "ckpt_uri": ckpt_uri,
52
+ "metadata": metadata or {},
53
+ },
54
+ )
55
+
56
+ def list(self, project_id: int) -> list[dict]:
57
+ resp = self._client.get(f"/projects/{project_id}/checkpoints")
58
+ return resp["items"]
59
+
60
+ def get(self, checkpoint_id: int) -> dict:
61
+ return self._client.get(f"/checkpoints/{checkpoint_id}")
62
+
63
+ def delete(self, checkpoint_id: int) -> None:
64
+ self._client.delete(f"/checkpoints/{checkpoint_id}")