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.
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/PKG-INFO +1 -1
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/pyproject.toml +1 -1
- x4d_devkit-0.2.0/tests/test_coordinate_frames.py +450 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/core/loader.py +150 -8
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit.egg-info/PKG-INFO +1 -1
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit.egg-info/SOURCES.txt +1 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/LICENSE +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/README.md +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/setup.cfg +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/tests/test_detection_eval.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/tests/test_loader.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/tests/test_matching.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/tests/test_metrics.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/tests/test_models.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/tests/test_nuscenes_converter.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/tests/test_token.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/tests/test_transform.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/tests/test_transform_chain.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/tests/test_validation.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/__init__.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/cli.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/client/__init__.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/client/client.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/client/clips.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/client/projects.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/converters/__init__.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/converters/nuscenes.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/core/__init__.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/core/models.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/core/token.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/core/transform.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/eval/__init__.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/eval/detection.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/eval/matching.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/eval/metrics.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/validation/__init__.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/validation/report.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit/validation/validator.py +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit.egg-info/dependency_links.txt +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit.egg-info/entry_points.txt +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit.egg-info/requires.txt +0 -0
- {x4d_devkit-0.1.0 → x4d_devkit-0.2.0}/x4d_devkit.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "x4d-devkit"
|
|
7
|
-
version = "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(
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|