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.
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/PKG-INFO +27 -7
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/README.md +26 -6
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/pyproject.toml +1 -1
- x4d_devkit-0.2.1/tests/test_coordinate_frames.py +450 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit/__init__.py +2 -0
- x4d_devkit-0.2.1/x4d_devkit/client/__init__.py +13 -0
- x4d_devkit-0.2.1/x4d_devkit/client/checkpoints.py +64 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit/client/client.py +23 -6
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit/client/clips.py +52 -8
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit/client/projects.py +11 -0
- x4d_devkit-0.2.1/x4d_devkit/client/test_sets.py +63 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit/core/loader.py +214 -13
- x4d_devkit-0.2.1/x4d_devkit/core/models.py +215 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit/core/transform.py +111 -14
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit/eval/detection.py +40 -1
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit/validation/report.py +20 -2
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit/validation/validator.py +25 -1
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit.egg-info/PKG-INFO +27 -7
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit.egg-info/SOURCES.txt +3 -0
- x4d_devkit-0.1.0/x4d_devkit/client/__init__.py +0 -3
- x4d_devkit-0.1.0/x4d_devkit/core/models.py +0 -106
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/LICENSE +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/setup.cfg +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/tests/test_detection_eval.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/tests/test_loader.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/tests/test_matching.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/tests/test_metrics.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/tests/test_models.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/tests/test_nuscenes_converter.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/tests/test_token.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/tests/test_transform.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/tests/test_transform_chain.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/tests/test_validation.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit/cli.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit/converters/__init__.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit/converters/nuscenes.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit/core/__init__.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit/core/token.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit/eval/__init__.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit/eval/matching.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit/eval/metrics.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit/validation/__init__.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit.egg-info/dependency_links.txt +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit.egg-info/entry_points.txt +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.1}/x4d_devkit.egg-info/requires.txt +0 -0
- {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
|
|
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
|
-
|
|
61
|
-
print(
|
|
60
|
+
loader = ClipLoader("/path/to/clip")
|
|
61
|
+
print(loader.meta)
|
|
62
62
|
|
|
63
|
-
for sample in
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
29
|
-
print(
|
|
28
|
+
loader = ClipLoader("/path/to/clip")
|
|
29
|
+
print(loader.meta)
|
|
30
30
|
|
|
31
|
-
for sample in
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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}")
|