x4d-devkit 0.9.0__tar.gz → 0.12.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {x4d_devkit-0.9.0/x4d_devkit.egg-info → x4d_devkit-0.12.0}/PKG-INFO +18 -9
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/README.md +17 -8
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/pyproject.toml +1 -1
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/tests/test_coordinate_frames.py +174 -131
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/tests/test_v06_schema.py +1 -1
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/converters/nuscenes.py +89 -28
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/core/loader.py +147 -121
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/core/models.py +11 -3
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/core/transform.py +89 -12
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/validation/validator.py +160 -21
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0/x4d_devkit.egg-info}/PKG-INFO +18 -9
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/LICENSE +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/setup.cfg +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/tests/test_box.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/tests/test_detection_eval.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/tests/test_evaluate.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/tests/test_fusion.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/tests/test_loader.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/tests/test_matching.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/tests/test_metrics.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/tests/test_models.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/tests/test_nuscenes_converter.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/tests/test_token.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/tests/test_transform.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/tests/test_transform_chain.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/tests/test_v06_api_additions.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/tests/test_validation.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/__init__.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/cli.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/client/__init__.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/client/checkpoints.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/client/client.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/client/clips.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/client/projects.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/client/test_sets.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/converters/__init__.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/core/__init__.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/core/token.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/eval/__init__.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/eval/api.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/eval/box.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/eval/detection.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/eval/matching.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/eval/metrics.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/fusion.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/validation/__init__.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit/validation/report.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit.egg-info/SOURCES.txt +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit.egg-info/dependency_links.txt +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit.egg-info/entry_points.txt +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit.egg-info/requires.txt +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.12.0}/x4d_devkit.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: x4d-devkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.12.0
|
|
4
4
|
Summary: X-4D dataset format SDK — load, validate, evaluate, and convert autonomous driving datasets
|
|
5
5
|
Author: windzu
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -65,22 +65,31 @@ for sample in loader.samples:
|
|
|
65
65
|
|
|
66
66
|
### Coordinate frame transforms
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
Frames are real frame_id strings: any node in the calibration tree (e.g.
|
|
69
|
+
`"LIDAR_TOP"`, `"base_link"`, `"cam_front"`) plus the constant
|
|
70
|
+
`"clip_world"` (the SLAM-anchored clip-local world). The legacy aliases
|
|
71
|
+
`"sensor"`, `"ego"`, `"world"` are not accepted.
|
|
69
72
|
|
|
70
73
|
```python
|
|
74
|
+
from x4d_devkit import ClipLoader
|
|
75
|
+
from x4d_devkit.core.loader import CLIP_WORLD_FRAME_ID
|
|
76
|
+
|
|
71
77
|
loader = ClipLoader("/path/to/clip")
|
|
72
78
|
sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
|
|
73
79
|
|
|
74
80
|
# Load point cloud in different frames
|
|
75
|
-
pts_sensor = loader.load_point_cloud(sd
|
|
76
|
-
pts_ego
|
|
77
|
-
pts_world
|
|
81
|
+
pts_sensor = loader.load_point_cloud(sd) # raw sensor (default)
|
|
82
|
+
pts_ego = loader.load_point_cloud(sd, frame=loader.ego_pose_frame_id) # sensor → ego
|
|
83
|
+
pts_world = loader.load_point_cloud(sd, frame=CLIP_WORLD_FRAME_ID) # sensor → clip_world
|
|
84
|
+
|
|
85
|
+
# Get annotations transformed to clip-local world
|
|
86
|
+
anns_world = loader.annotations_for_sample(sample.token, frame=CLIP_WORLD_FRAME_ID)
|
|
78
87
|
|
|
79
|
-
#
|
|
80
|
-
|
|
88
|
+
# Or to a specific sensor's frame
|
|
89
|
+
anns_lidar = loader.annotations_for_sample(sample.token, frame="LIDAR_TOP")
|
|
81
90
|
|
|
82
|
-
# Get the transform matrix directly
|
|
83
|
-
T = loader.get_transform(sd,
|
|
91
|
+
# Get the transform matrix directly (sd is required when clip_world is involved)
|
|
92
|
+
T = loader.get_transform(loader.sensor_frame_id(sd), CLIP_WORLD_FRAME_ID, sd=sd)
|
|
84
93
|
pts_world = T.apply(pts_sensor[:, :3]) # or use T.as_matrix for 4x4
|
|
85
94
|
```
|
|
86
95
|
|
|
@@ -35,22 +35,31 @@ for sample in loader.samples:
|
|
|
35
35
|
|
|
36
36
|
### Coordinate frame transforms
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
Frames are real frame_id strings: any node in the calibration tree (e.g.
|
|
39
|
+
`"LIDAR_TOP"`, `"base_link"`, `"cam_front"`) plus the constant
|
|
40
|
+
`"clip_world"` (the SLAM-anchored clip-local world). The legacy aliases
|
|
41
|
+
`"sensor"`, `"ego"`, `"world"` are not accepted.
|
|
39
42
|
|
|
40
43
|
```python
|
|
44
|
+
from x4d_devkit import ClipLoader
|
|
45
|
+
from x4d_devkit.core.loader import CLIP_WORLD_FRAME_ID
|
|
46
|
+
|
|
41
47
|
loader = ClipLoader("/path/to/clip")
|
|
42
48
|
sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
|
|
43
49
|
|
|
44
50
|
# Load point cloud in different frames
|
|
45
|
-
pts_sensor = loader.load_point_cloud(sd
|
|
46
|
-
pts_ego
|
|
47
|
-
pts_world
|
|
51
|
+
pts_sensor = loader.load_point_cloud(sd) # raw sensor (default)
|
|
52
|
+
pts_ego = loader.load_point_cloud(sd, frame=loader.ego_pose_frame_id) # sensor → ego
|
|
53
|
+
pts_world = loader.load_point_cloud(sd, frame=CLIP_WORLD_FRAME_ID) # sensor → clip_world
|
|
54
|
+
|
|
55
|
+
# Get annotations transformed to clip-local world
|
|
56
|
+
anns_world = loader.annotations_for_sample(sample.token, frame=CLIP_WORLD_FRAME_ID)
|
|
48
57
|
|
|
49
|
-
#
|
|
50
|
-
|
|
58
|
+
# Or to a specific sensor's frame
|
|
59
|
+
anns_lidar = loader.annotations_for_sample(sample.token, frame="LIDAR_TOP")
|
|
51
60
|
|
|
52
|
-
# Get the transform matrix directly
|
|
53
|
-
T = loader.get_transform(sd,
|
|
61
|
+
# Get the transform matrix directly (sd is required when clip_world is involved)
|
|
62
|
+
T = loader.get_transform(loader.sensor_frame_id(sd), CLIP_WORLD_FRAME_ID, sd=sd)
|
|
54
63
|
pts_world = T.apply(pts_sensor[:, :3]) # or use T.as_matrix for 4x4
|
|
55
64
|
```
|
|
56
65
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "x4d-devkit"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.12.0"
|
|
8
8
|
description = "X-4D dataset format SDK — load, validate, evaluate, and convert autonomous driving datasets"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
"""Tests for ClipLoader coordinate frame support.
|
|
1
|
+
"""Tests for ClipLoader coordinate frame support — frame-graph (post-magic-string) API.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
The new contract: every frame is a real frame_id string (a node in the
|
|
4
|
+
calibration tree) plus the constant ``"clip_world"`` for the SLAM-anchored
|
|
5
|
+
clip-local world. The legacy magic strings ``"sensor"``, ``"ego"``, ``"world"``
|
|
6
|
+
are rejected to remove the "did you mean my-actual-frame_id-string?" foot-gun.
|
|
7
|
+
|
|
8
|
+
Unit tests use synthetic data (no nuScenes dependency). Integration tests
|
|
9
|
+
require nuScenes mini data.
|
|
5
10
|
"""
|
|
6
11
|
|
|
7
12
|
import json
|
|
@@ -12,8 +17,9 @@ import numpy as np
|
|
|
12
17
|
import pytest
|
|
13
18
|
|
|
14
19
|
from x4d_devkit.core.loader import ClipLoader
|
|
15
|
-
|
|
16
|
-
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
CLIP_WORLD = "clip_world"
|
|
17
23
|
|
|
18
24
|
|
|
19
25
|
# ---------------------------------------------------------------------------
|
|
@@ -157,7 +163,7 @@ def _make_synthetic_clip(tmp_path: Path) -> Path:
|
|
|
157
163
|
pts.tofile(str(lidar_dir / "frame0.bin"))
|
|
158
164
|
pts.tofile(str(lidar_dir / "frame1.bin"))
|
|
159
165
|
|
|
160
|
-
# -- annotation.json (v0.5: per-sample ego frame) --
|
|
166
|
+
# -- annotation.json (v0.5: per-sample ego frame; here ann_frame == base_link) --
|
|
161
167
|
# s-0 ego at origin; s-1 ego at (10, 0, 0). Same world pos (5,3,0) and (10,3,0)
|
|
162
168
|
# for the two frames maps to ego (5,3,0) and (0,3,0) respectively.
|
|
163
169
|
(clip_dir / "annotation.json").write_text(json.dumps([
|
|
@@ -224,186 +230,220 @@ def loader(synthetic_clip):
|
|
|
224
230
|
|
|
225
231
|
|
|
226
232
|
# ---------------------------------------------------------------------------
|
|
227
|
-
#
|
|
233
|
+
# Frame-id helpers (the user-facing way to discover frame names)
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
class TestFrameIdHelpers:
|
|
237
|
+
def test_ego_pose_frame_id_exposes_ego_frame(self, loader):
|
|
238
|
+
# v0.2 fallback chain: meta.ego_pose_frame_id None → annotation_frame_id None → "base_link".
|
|
239
|
+
assert loader.ego_pose_frame_id == "base_link"
|
|
240
|
+
|
|
241
|
+
def test_annotation_frame_id_exposed(self, loader):
|
|
242
|
+
assert loader.annotation_frame_id == "base_link"
|
|
243
|
+
|
|
244
|
+
def test_sensor_frame_id_returns_channel_frame(self, loader):
|
|
245
|
+
sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
|
|
246
|
+
# v0.2 has no per-sensor frame_id, falls back to channel name.
|
|
247
|
+
assert loader.sensor_frame_id(sd) == "LIDAR_TOP"
|
|
248
|
+
|
|
249
|
+
def test_clip_world_constant_is_string_literal(self):
|
|
250
|
+
# The spec pins this name. Any consumer can pass it as a regular frame.
|
|
251
|
+
from x4d_devkit.core.loader import CLIP_WORLD_FRAME_ID
|
|
252
|
+
assert CLIP_WORLD_FRAME_ID == "clip_world"
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ---------------------------------------------------------------------------
|
|
256
|
+
# load_point_cloud — explicit frame_id, no magic strings
|
|
228
257
|
# ---------------------------------------------------------------------------
|
|
229
258
|
|
|
230
259
|
class TestLoadPointCloudFrames:
|
|
231
|
-
def
|
|
232
|
-
"""Default frame
|
|
260
|
+
def test_no_frame_returns_raw_sensor(self, loader):
|
|
261
|
+
"""Default (no frame=) returns the raw sensor data unchanged."""
|
|
233
262
|
sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
|
|
234
263
|
pts = loader.load_point_cloud(sd)
|
|
235
264
|
assert pts.shape == (4, 4)
|
|
236
265
|
np.testing.assert_allclose(pts[0, :3], [1.0, 0.0, 0.0], atol=1e-6)
|
|
237
266
|
|
|
238
|
-
def
|
|
239
|
-
"""
|
|
267
|
+
def test_frame_equal_to_sensor_frame_is_noop(self, loader):
|
|
268
|
+
"""Passing the sensor's own frame_id matches the no-frame default."""
|
|
240
269
|
sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
|
|
241
270
|
pts_default = loader.load_point_cloud(sd)
|
|
242
|
-
|
|
243
|
-
np.testing.assert_array_equal(pts_default,
|
|
271
|
+
pts_explicit = loader.load_point_cloud(sd, frame=loader.sensor_frame_id(sd))
|
|
272
|
+
np.testing.assert_array_equal(pts_default, pts_explicit)
|
|
244
273
|
|
|
245
|
-
def
|
|
246
|
-
"""frame=
|
|
274
|
+
def test_frame_equal_to_ego_applies_calibration(self, loader):
|
|
275
|
+
"""frame=ego_pose_frame_id applies sensor-to-ego (z+1 for LIDAR_TOP)."""
|
|
247
276
|
sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
|
|
248
|
-
pts = loader.load_point_cloud(sd, frame=
|
|
249
|
-
# LiDAR is at z=1 in ego frame, so sensor point (1,0,0) -> ego (1,0,1)
|
|
277
|
+
pts = loader.load_point_cloud(sd, frame=loader.ego_pose_frame_id)
|
|
250
278
|
np.testing.assert_allclose(pts[0, :3], [1.0, 0.0, 1.0], atol=1e-5)
|
|
251
|
-
# sensor (0,0,1) -> ego (0,0,2)
|
|
252
279
|
np.testing.assert_allclose(pts[2, :3], [0.0, 0.0, 2.0], atol=1e-5)
|
|
253
280
|
|
|
254
|
-
def
|
|
255
|
-
"""frame='
|
|
281
|
+
def test_frame_clip_world_first_ego(self, loader):
|
|
282
|
+
"""frame='clip_world' for the first ego pose (at origin) ≡ ego frame."""
|
|
256
283
|
sd = loader.sample_data_for_channel("LIDAR_TOP")[0] # ep-0 at origin
|
|
257
|
-
pts_ego = loader.load_point_cloud(sd, frame=
|
|
258
|
-
pts_world = loader.load_point_cloud(sd, frame=
|
|
284
|
+
pts_ego = loader.load_point_cloud(sd, frame=loader.ego_pose_frame_id)
|
|
285
|
+
pts_world = loader.load_point_cloud(sd, frame=CLIP_WORLD)
|
|
259
286
|
np.testing.assert_allclose(pts_world[:, :3], pts_ego[:, :3], atol=1e-5)
|
|
260
287
|
|
|
261
|
-
def
|
|
262
|
-
"""frame='
|
|
288
|
+
def test_frame_clip_world_second_ego(self, loader):
|
|
289
|
+
"""frame='clip_world' for the second ego pose adds the ego offset."""
|
|
263
290
|
sd = loader.sample_data_for_channel("LIDAR_TOP")[1] # ep-1 at x=10
|
|
264
|
-
pts = loader.load_point_cloud(sd, frame=
|
|
265
|
-
# sensor (1,0,0) -> ego (1,0,1) -> world (11,0,1)
|
|
291
|
+
pts = loader.load_point_cloud(sd, frame=CLIP_WORLD)
|
|
266
292
|
np.testing.assert_allclose(pts[0, :3], [11.0, 0.0, 1.0], atol=1e-5)
|
|
267
293
|
|
|
268
294
|
def test_intensity_preserved(self, loader):
|
|
269
|
-
"""Non-xyz columns
|
|
295
|
+
"""Non-xyz columns are passed through unchanged."""
|
|
270
296
|
sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
|
|
271
|
-
pts = loader.load_point_cloud(sd, frame=
|
|
297
|
+
pts = loader.load_point_cloud(sd, frame=CLIP_WORLD)
|
|
272
298
|
np.testing.assert_allclose(pts[:, 3], 0.5, atol=1e-6)
|
|
273
299
|
|
|
274
|
-
def
|
|
300
|
+
def test_magic_string_world_rejected(self, loader):
|
|
301
|
+
"""The legacy magic string 'world' must be rejected with a hint."""
|
|
275
302
|
sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
|
|
276
|
-
with pytest.raises(ValueError, match="
|
|
277
|
-
loader.load_point_cloud(sd, frame="
|
|
303
|
+
with pytest.raises(ValueError, match=r"clip_world"):
|
|
304
|
+
loader.load_point_cloud(sd, frame="world")
|
|
305
|
+
|
|
306
|
+
def test_magic_string_ego_rejected(self, loader):
|
|
307
|
+
sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
|
|
308
|
+
with pytest.raises(ValueError, match=r"ego_pose_frame_id"):
|
|
309
|
+
loader.load_point_cloud(sd, frame="ego")
|
|
310
|
+
|
|
311
|
+
def test_magic_string_sensor_rejected(self, loader):
|
|
312
|
+
sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
|
|
313
|
+
with pytest.raises(ValueError, match=r"sensor_frame_id"):
|
|
314
|
+
loader.load_point_cloud(sd, frame="sensor")
|
|
315
|
+
|
|
316
|
+
def test_unknown_frame_id_raises_keyerror(self, loader):
|
|
317
|
+
sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
|
|
318
|
+
with pytest.raises(KeyError, match=r"not_a_real_frame"):
|
|
319
|
+
loader.load_point_cloud(sd, frame="not_a_real_frame")
|
|
278
320
|
|
|
279
321
|
|
|
280
322
|
# ---------------------------------------------------------------------------
|
|
281
|
-
#
|
|
323
|
+
# annotations_for_sample — explicit frame_id, no magic strings
|
|
282
324
|
# ---------------------------------------------------------------------------
|
|
283
325
|
|
|
284
326
|
class TestAnnotationsForSampleFrames:
|
|
285
|
-
def
|
|
286
|
-
"""Default frame
|
|
327
|
+
def test_no_frame_returns_native(self, loader):
|
|
328
|
+
"""Default (no frame=) returns annotations in the stored frame, unchanged."""
|
|
287
329
|
anns = loader.annotations_for_sample("s-0")
|
|
288
330
|
assert len(anns) == 1
|
|
289
331
|
np.testing.assert_allclose(anns[0].translation, [5.0, 3.0, 0.0])
|
|
290
332
|
|
|
291
|
-
def
|
|
292
|
-
"""
|
|
333
|
+
def test_frame_equal_to_annotation_frame_is_noop(self, loader):
|
|
334
|
+
"""Passing annotation_frame_id matches the no-frame default."""
|
|
293
335
|
anns_default = loader.annotations_for_sample("s-0")
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
""
|
|
300
|
-
anns = loader.annotations_for_sample("s-0", frame="world")
|
|
301
|
-
# ego at origin -> ego-to-world is identity
|
|
336
|
+
anns_explicit = loader.annotations_for_sample("s-0", frame=loader.annotation_frame_id)
|
|
337
|
+
np.testing.assert_allclose(anns_default[0].translation, anns_explicit[0].translation)
|
|
338
|
+
|
|
339
|
+
def test_frame_clip_world_first_sample(self, loader):
|
|
340
|
+
"""frame='clip_world' on the first sample (ego at origin) ≡ stored ego."""
|
|
341
|
+
anns = loader.annotations_for_sample("s-0", frame=CLIP_WORLD)
|
|
302
342
|
np.testing.assert_allclose(anns[0].translation, [5.0, 3.0, 0.0], atol=1e-5)
|
|
303
343
|
|
|
304
|
-
def
|
|
305
|
-
"""frame='
|
|
306
|
-
anns = loader.annotations_for_sample("s-1", frame=
|
|
307
|
-
# ego frame (0,3,0), ego at (10,0,0) -> world (10,3,0)
|
|
344
|
+
def test_frame_clip_world_second_sample(self, loader):
|
|
345
|
+
"""frame='clip_world' on the second sample (ego at x=10) shifts by ego offset."""
|
|
346
|
+
anns = loader.annotations_for_sample("s-1", frame=CLIP_WORLD)
|
|
308
347
|
np.testing.assert_allclose(anns[0].translation, [10.0, 3.0, 0.0], atol=1e-5)
|
|
309
348
|
|
|
310
|
-
def
|
|
311
|
-
|
|
312
|
-
anns = loader.annotations_for_sample("s-0", frame="ego")
|
|
349
|
+
def test_native_velocity_unchanged(self, loader):
|
|
350
|
+
anns = loader.annotations_for_sample("s-0")
|
|
313
351
|
np.testing.assert_allclose(anns[0].velocity, [5.0, 0.0, 0.0], atol=1e-5)
|
|
314
352
|
|
|
315
|
-
def
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
assert
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
353
|
+
def test_clip_world_preserves_other_fields(self, loader):
|
|
354
|
+
anns_native = loader.annotations_for_sample("s-0")
|
|
355
|
+
anns_world = loader.annotations_for_sample("s-0", frame=CLIP_WORLD)
|
|
356
|
+
assert anns_native[0].token == anns_world[0].token
|
|
357
|
+
assert anns_native[0].category == anns_world[0].category
|
|
358
|
+
np.testing.assert_allclose(anns_native[0].size, anns_world[0].size)
|
|
359
|
+
assert anns_native[0].num_lidar_pts == anns_world[0].num_lidar_pts
|
|
360
|
+
|
|
361
|
+
def test_returns_new_objects(self, loader):
|
|
362
|
+
"""Transformed annotations are new objects; originals untouched."""
|
|
363
|
+
anns_native = loader.annotations_for_sample("s-1")
|
|
364
|
+
anns_world = loader.annotations_for_sample("s-1", frame=CLIP_WORLD)
|
|
365
|
+
np.testing.assert_allclose(anns_native[0].translation, [0.0, 3.0, 0.0])
|
|
366
|
+
assert not np.allclose(anns_world[0].translation, anns_native[0].translation, atol=0.1)
|
|
367
|
+
|
|
368
|
+
def test_frame_sensor_id_transforms_to_that_sensor(self, loader):
|
|
369
|
+
"""Passing a sensor's frame_id transforms annotations into that sensor's frame."""
|
|
370
|
+
# LIDAR_TOP is at (0,0,1) in ego frame. Annotation at ego (5,3,0) →
|
|
371
|
+
# LIDAR_TOP frame: subtract z=1 → (5,3,-1).
|
|
372
|
+
anns = loader.annotations_for_sample("s-0", frame="LIDAR_TOP")
|
|
373
|
+
np.testing.assert_allclose(anns[0].translation, [5.0, 3.0, -1.0], atol=1e-5)
|
|
374
|
+
|
|
375
|
+
def test_magic_string_sensor_rejected(self, loader):
|
|
376
|
+
with pytest.raises(ValueError, match=r"sensor_frame_id"):
|
|
335
377
|
loader.annotations_for_sample("s-0", frame="sensor")
|
|
336
378
|
|
|
337
|
-
def
|
|
338
|
-
with pytest.raises(ValueError, match="
|
|
339
|
-
loader.annotations_for_sample("s-0", frame="
|
|
379
|
+
def test_magic_string_ego_rejected(self, loader):
|
|
380
|
+
with pytest.raises(ValueError, match=r"ego_pose_frame_id"):
|
|
381
|
+
loader.annotations_for_sample("s-0", frame="ego")
|
|
382
|
+
|
|
383
|
+
def test_magic_string_world_rejected(self, loader):
|
|
384
|
+
with pytest.raises(ValueError, match=r"clip_world"):
|
|
385
|
+
loader.annotations_for_sample("s-0", frame="world")
|
|
386
|
+
|
|
387
|
+
def test_unknown_frame_id_raises(self, loader):
|
|
388
|
+
with pytest.raises((KeyError, ValueError), match=r"not_a_real_frame"):
|
|
389
|
+
loader.annotations_for_sample("s-0", frame="not_a_real_frame")
|
|
340
390
|
|
|
341
391
|
def test_empty_sample_returns_empty(self, loader):
|
|
342
|
-
""
|
|
343
|
-
assert loader.annotations_for_sample("nonexistent", frame=
|
|
392
|
+
assert loader.annotations_for_sample("nonexistent") == []
|
|
393
|
+
assert loader.annotations_for_sample("nonexistent", frame=CLIP_WORLD) == []
|
|
344
394
|
|
|
345
395
|
|
|
346
396
|
# ---------------------------------------------------------------------------
|
|
347
|
-
#
|
|
397
|
+
# get_transform — frame_id × frame_id, sd carries dynamic context
|
|
348
398
|
# ---------------------------------------------------------------------------
|
|
349
399
|
|
|
350
400
|
class TestGetTransform:
|
|
351
401
|
def test_identity_same_frame(self, loader):
|
|
352
|
-
|
|
353
|
-
T = loader.get_transform(sd, from_frame="sensor", to_frame="sensor")
|
|
402
|
+
T = loader.get_transform("LIDAR_TOP", "LIDAR_TOP")
|
|
354
403
|
np.testing.assert_allclose(T.as_matrix, np.eye(4), atol=1e-10)
|
|
355
404
|
|
|
356
|
-
def
|
|
357
|
-
sd
|
|
358
|
-
T = loader.get_transform(
|
|
359
|
-
# LiDAR at z=1
|
|
405
|
+
def test_static_sensor_to_ego(self, loader):
|
|
406
|
+
# Static-only: no sd needed.
|
|
407
|
+
T = loader.get_transform("LIDAR_TOP", loader.ego_pose_frame_id)
|
|
360
408
|
np.testing.assert_allclose(T.translation, [0.0, 0.0, 1.0], atol=1e-10)
|
|
361
409
|
|
|
362
|
-
def
|
|
410
|
+
def test_static_sensor_to_sensor(self, loader):
|
|
411
|
+
# LIDAR_TOP at (0,0,1) and CAM_FRONT at (0.5,0,0.8) in ego frame.
|
|
412
|
+
# T_CAM_from_LIDAR = T_CAM_from_ego @ T_ego_from_LIDAR
|
|
413
|
+
# = (-0.5, 0, -0.8) ∘ (0, 0, +1) = (-0.5, 0, +0.2)
|
|
414
|
+
T = loader.get_transform("LIDAR_TOP", "CAM_FRONT")
|
|
415
|
+
np.testing.assert_allclose(T.translation, [-0.5, 0.0, 0.2], atol=1e-10)
|
|
416
|
+
|
|
417
|
+
def test_dynamic_sensor_to_clip_world_requires_sd(self, loader):
|
|
363
418
|
sd = loader.sample_data_for_channel("LIDAR_TOP")[1] # ego at x=10
|
|
364
|
-
T = loader.get_transform(
|
|
365
|
-
# sensor
|
|
419
|
+
T = loader.get_transform("LIDAR_TOP", CLIP_WORLD, sd=sd)
|
|
420
|
+
# sensor → ego (z+1) → world (x+10) → (10, 0, 1)
|
|
366
421
|
np.testing.assert_allclose(T.translation, [10.0, 0.0, 1.0], atol=1e-10)
|
|
367
422
|
|
|
368
|
-
def
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
np.testing.assert_allclose(T.translation, [10.0, 0.0, 0.0], atol=1e-10)
|
|
423
|
+
def test_clip_world_without_sd_raises(self, loader):
|
|
424
|
+
with pytest.raises(ValueError, match=r"clip_world"):
|
|
425
|
+
loader.get_transform("LIDAR_TOP", CLIP_WORLD)
|
|
372
426
|
|
|
373
|
-
def
|
|
374
|
-
"""sensor→world→sensor should be identity."""
|
|
427
|
+
def test_round_trip_through_clip_world(self, loader):
|
|
375
428
|
sd = loader.sample_data_for_channel("LIDAR_TOP")[1]
|
|
376
|
-
T_fwd = loader.get_transform(
|
|
377
|
-
T_rev = loader.get_transform(
|
|
378
|
-
|
|
379
|
-
np.testing.assert_allclose(T_round.as_matrix, np.eye(4), atol=1e-10)
|
|
429
|
+
T_fwd = loader.get_transform("LIDAR_TOP", CLIP_WORLD, sd=sd)
|
|
430
|
+
T_rev = loader.get_transform(CLIP_WORLD, "LIDAR_TOP", sd=sd)
|
|
431
|
+
np.testing.assert_allclose((T_fwd @ T_rev).as_matrix, np.eye(4), atol=1e-10)
|
|
380
432
|
|
|
381
|
-
def
|
|
382
|
-
"""All from/to combinations should work without error."""
|
|
433
|
+
def test_magic_string_rejected(self, loader):
|
|
383
434
|
sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
for t in frames:
|
|
387
|
-
T = loader.get_transform(sd, from_frame=f, to_frame=t)
|
|
388
|
-
assert T.as_matrix.shape == (4, 4)
|
|
435
|
+
with pytest.raises(ValueError, match=r"clip_world"):
|
|
436
|
+
loader.get_transform("sensor", "world", sd=sd)
|
|
389
437
|
|
|
390
|
-
def
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
loader.get_transform(sd, from_frame="bad", to_frame="ego")
|
|
394
|
-
|
|
395
|
-
def test_invalid_to_frame_raises(self, loader):
|
|
396
|
-
sd = loader.sample_data_for_channel("LIDAR_TOP")[0]
|
|
397
|
-
with pytest.raises(ValueError, match="Invalid to_frame"):
|
|
398
|
-
loader.get_transform(sd, from_frame="sensor", to_frame="bad")
|
|
438
|
+
def test_unknown_frame_id_raises_keyerror(self, loader):
|
|
439
|
+
with pytest.raises(KeyError, match=r"not_a_real_frame"):
|
|
440
|
+
loader.get_transform("LIDAR_TOP", "not_a_real_frame")
|
|
399
441
|
|
|
400
442
|
def test_consistency_with_load_point_cloud(self, loader):
|
|
401
|
-
"""get_transform result should match load_point_cloud frame output."""
|
|
402
443
|
sd = loader.sample_data_for_channel("LIDAR_TOP")[1]
|
|
403
|
-
pts_sensor = loader.load_point_cloud(sd
|
|
404
|
-
pts_world = loader.load_point_cloud(sd, frame=
|
|
405
|
-
|
|
406
|
-
T = loader.get_transform(sd, from_frame="sensor", to_frame="world")
|
|
444
|
+
pts_sensor = loader.load_point_cloud(sd)
|
|
445
|
+
pts_world = loader.load_point_cloud(sd, frame=CLIP_WORLD)
|
|
446
|
+
T = loader.get_transform("LIDAR_TOP", CLIP_WORLD, sd=sd)
|
|
407
447
|
pts_manual = T.apply(pts_sensor[:, :3].astype(np.float64)).astype(np.float32)
|
|
408
448
|
np.testing.assert_allclose(pts_world[:, :3], pts_manual, atol=1e-5)
|
|
409
449
|
|
|
@@ -434,25 +474,28 @@ def nuscenes_loader(tmp_path_factory):
|
|
|
434
474
|
@pytest.mark.skipif(not NUSCENES_AVAILABLE, reason="nuScenes data not found")
|
|
435
475
|
class TestCoordinateFramesIntegration:
|
|
436
476
|
def test_point_cloud_round_trip(self, nuscenes_loader):
|
|
437
|
-
"""sensor→
|
|
477
|
+
"""sensor → clip_world → sensor recovers original points."""
|
|
438
478
|
sd = next(
|
|
439
479
|
s for s in nuscenes_loader.sample_data_for_channel("LIDAR_TOP") if s.is_keyframe
|
|
440
480
|
)
|
|
441
|
-
pts_sensor = nuscenes_loader.load_point_cloud(sd
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
recovered =
|
|
481
|
+
pts_sensor = nuscenes_loader.load_point_cloud(sd)
|
|
482
|
+
pts_world = nuscenes_loader.load_point_cloud(sd, frame=CLIP_WORLD)
|
|
483
|
+
T_back = nuscenes_loader.get_transform(CLIP_WORLD, "LIDAR_TOP", sd=sd)
|
|
484
|
+
recovered = T_back.apply(pts_world[:, :3].astype(np.float64)).astype(np.float32)
|
|
445
485
|
np.testing.assert_allclose(recovered, pts_sensor[:, :3], atol=1e-4)
|
|
446
486
|
|
|
447
487
|
def test_ego_annotations_near_ego(self, nuscenes_loader):
|
|
448
|
-
"""Ego-frame annotations should be centered around the ego vehicle."""
|
|
449
488
|
sample = nuscenes_loader.samples[0]
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
if not
|
|
489
|
+
anns_native = nuscenes_loader.annotations_for_sample(sample.token)
|
|
490
|
+
anns_world = nuscenes_loader.annotations_for_sample(sample.token, frame=CLIP_WORLD)
|
|
491
|
+
if not anns_native:
|
|
453
492
|
pytest.skip("No annotations in first sample")
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
493
|
+
for ann in anns_native:
|
|
494
|
+
dist = np.linalg.norm(ann.translation[:2])
|
|
495
|
+
assert dist < 200, f"Annotation too far in native frame: {dist}m"
|
|
496
|
+
# World frame should differ for at least one annotation (ego usually not at origin).
|
|
497
|
+
diffs = [
|
|
498
|
+
np.linalg.norm(an.translation - aw.translation)
|
|
499
|
+
for an, aw in zip(anns_native, anns_world)
|
|
500
|
+
]
|
|
501
|
+
assert max(diffs) > 0 # at minimum, different positions
|
|
@@ -274,7 +274,7 @@ def test_annotations_for_sample_unknown_frame_id_raises(tmp_path):
|
|
|
274
274
|
}],
|
|
275
275
|
)
|
|
276
276
|
loader = ClipLoader(tmp_path)
|
|
277
|
-
with pytest.raises(
|
|
277
|
+
with pytest.raises(KeyError, match="never_exists_frame"):
|
|
278
278
|
loader.annotations_for_sample("s-0", frame="never_exists_frame")
|
|
279
279
|
|
|
280
280
|
|