x4d-devkit 0.11.1__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.11.1/x4d_devkit.egg-info → x4d_devkit-0.12.0}/PKG-INFO +18 -9
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/README.md +17 -8
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/pyproject.toml +1 -1
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/tests/test_coordinate_frames.py +174 -131
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/tests/test_v06_schema.py +1 -1
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/core/loader.py +133 -121
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/core/models.py +1 -1
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0/x4d_devkit.egg-info}/PKG-INFO +18 -9
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/LICENSE +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/setup.cfg +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/tests/test_box.py +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/tests/test_detection_eval.py +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/tests/test_evaluate.py +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/tests/test_fusion.py +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/tests/test_loader.py +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/tests/test_matching.py +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/tests/test_metrics.py +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/tests/test_models.py +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/tests/test_nuscenes_converter.py +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/tests/test_token.py +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/tests/test_transform.py +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/tests/test_transform_chain.py +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/tests/test_v06_api_additions.py +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/tests/test_validation.py +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/__init__.py +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/cli.py +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/client/__init__.py +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/client/checkpoints.py +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/client/client.py +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/client/clips.py +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/client/projects.py +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/client/test_sets.py +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/converters/__init__.py +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/converters/nuscenes.py +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/core/__init__.py +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/core/token.py +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/core/transform.py +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/eval/__init__.py +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/eval/api.py +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/eval/box.py +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/eval/detection.py +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/eval/matching.py +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/eval/metrics.py +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/fusion.py +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/validation/__init__.py +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/validation/report.py +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit/validation/validator.py +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit.egg-info/SOURCES.txt +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit.egg-info/dependency_links.txt +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit.egg-info/entry_points.txt +0 -0
- {x4d_devkit-0.11.1 → x4d_devkit-0.12.0}/x4d_devkit.egg-info/requires.txt +0 -0
- {x4d_devkit-0.11.1 → 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
|
|
|
@@ -1,11 +1,20 @@
|
|
|
1
|
-
"""ClipLoader: load an X4D clip directory into typed dataclasses.
|
|
1
|
+
"""ClipLoader: load an X4D clip directory into typed dataclasses.
|
|
2
|
+
|
|
3
|
+
Coordinate frames in this loader are *real frame_id strings only* — every
|
|
4
|
+
frame is either a node in the clip's calibration tree (e.g. ``"LIDAR_TOP"``,
|
|
5
|
+
``"base_link"``, ``"cam_front"``) or the constant ``"clip_world"`` for the
|
|
6
|
+
SLAM-anchored clip-local world. The legacy magic aliases ``"sensor"``,
|
|
7
|
+
``"ego"``, ``"world"`` are no longer accepted; use the helpers
|
|
8
|
+
:meth:`ClipLoader.sensor_frame_id`, :attr:`ClipLoader.ego_pose_frame_id`,
|
|
9
|
+
and :data:`CLIP_WORLD_FRAME_ID` instead.
|
|
10
|
+
"""
|
|
2
11
|
|
|
3
12
|
from __future__ import annotations
|
|
4
13
|
|
|
5
14
|
import json
|
|
6
15
|
from dataclasses import replace
|
|
7
16
|
from pathlib import Path
|
|
8
|
-
from typing import Any
|
|
17
|
+
from typing import Any
|
|
9
18
|
|
|
10
19
|
import numpy as np
|
|
11
20
|
|
|
@@ -21,14 +30,34 @@ from x4d_devkit.core.models import (
|
|
|
21
30
|
)
|
|
22
31
|
from x4d_devkit.core.transform import Transform, TransformChain, rotation_matrix_to_quat
|
|
23
32
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
|
|
34
|
+
CLIP_WORLD_FRAME_ID = "clip_world"
|
|
35
|
+
"""The constant frame_id of the SLAM-anchored clip-local world.
|
|
36
|
+
|
|
37
|
+
Defined by the X-4D dataset spec (v0.8+). Not a node in
|
|
38
|
+
``calibrated_sensor.json``; it is the dynamic edge wired through
|
|
39
|
+
``ego_pose.json`` (``ego_pose[t]: clip_world ← ego_pose_frame_id``).
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
_MAGIC_FRAME_REPLACEMENTS = {
|
|
43
|
+
"sensor": "loader.sensor_frame_id(sd) (e.g. 'LIDAR_TOP')",
|
|
44
|
+
"ego": "loader.ego_pose_frame_id (e.g. 'base_link')",
|
|
45
|
+
"world": f"the constant {CLIP_WORLD_FRAME_ID!r} "
|
|
46
|
+
"(also exported as x4d_devkit.core.loader.CLIP_WORLD_FRAME_ID)",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _reject_magic_frame(frame: str) -> None:
|
|
51
|
+
"""Reject the legacy magic aliases ('sensor' / 'ego' / 'world') with a hint."""
|
|
52
|
+
if frame in _MAGIC_FRAME_REPLACEMENTS:
|
|
53
|
+
replacements = "; ".join(
|
|
54
|
+
f"{alias!r} → {hint}" for alias, hint in _MAGIC_FRAME_REPLACEMENTS.items()
|
|
55
|
+
)
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"frame {frame!r} is not a valid frame_id — the legacy magic aliases "
|
|
58
|
+
f"'sensor', 'ego', 'world' were removed. Use real frame_ids instead: "
|
|
59
|
+
f"{replacements}."
|
|
60
|
+
)
|
|
32
61
|
|
|
33
62
|
|
|
34
63
|
class SchemaVersionMismatch(ValueError):
|
|
@@ -76,12 +105,13 @@ class ClipLoader:
|
|
|
76
105
|
for sample in loader.samples:
|
|
77
106
|
for sd in loader.sample_data_for_sample(sample.token):
|
|
78
107
|
if sd.channel in lidar_channels:
|
|
79
|
-
pts = loader.load_point_cloud(sd, frame=
|
|
108
|
+
pts = loader.load_point_cloud(sd, frame=loader.ego_pose_frame_id)
|
|
80
109
|
|
|
81
|
-
#
|
|
110
|
+
# Annotations in their stored frame:
|
|
82
111
|
anns = loader.annotations_for_sample(sample.token)
|
|
83
|
-
# Or transform to
|
|
84
|
-
|
|
112
|
+
# Or transform to clip-local world / a specific sensor frame:
|
|
113
|
+
anns_world = loader.annotations_for_sample(sample.token, frame=CLIP_WORLD_FRAME_ID)
|
|
114
|
+
anns_lidar = loader.annotations_for_sample(sample.token, frame="LIDAR_TOP")
|
|
85
115
|
"""
|
|
86
116
|
|
|
87
117
|
def __init__(
|
|
@@ -288,8 +318,7 @@ class ClipLoader:
|
|
|
288
318
|
|
|
289
319
|
Returns ``meta.annotation_frame_id`` if explicitly set (v0.6+ clips).
|
|
290
320
|
Pre-v0.6 clips fall back to ``"base_link"`` — the per-sample ego frame
|
|
291
|
-
all earlier loader code already assumed
|
|
292
|
-
was a no-op transform).
|
|
321
|
+
all earlier loader code already assumed.
|
|
293
322
|
"""
|
|
294
323
|
if self._meta.annotation_frame_id is not None:
|
|
295
324
|
return self._meta.annotation_frame_id
|
|
@@ -319,6 +348,15 @@ class ClipLoader:
|
|
|
319
348
|
return info.frame_id
|
|
320
349
|
return channel
|
|
321
350
|
|
|
351
|
+
def sensor_frame_id(self, sd: SampleData) -> str:
|
|
352
|
+
"""ROS frame_id of the sensor that produced ``sd``.
|
|
353
|
+
|
|
354
|
+
Sugar for ``frame_id_for_channel(sd.channel)`` — the canonical way to
|
|
355
|
+
get the source frame_id for a piece of sample_data when calling
|
|
356
|
+
:meth:`load_point_cloud` / :meth:`get_transform`.
|
|
357
|
+
"""
|
|
358
|
+
return self.frame_id_for_channel(sd.channel)
|
|
359
|
+
|
|
322
360
|
def require_schema_version(self, expected: "str | set[str]") -> None:
|
|
323
361
|
"""Raise ``SchemaVersionMismatch`` if the clip's schema_version isn't in ``expected``.
|
|
324
362
|
|
|
@@ -361,7 +399,7 @@ class ClipLoader:
|
|
|
361
399
|
|
|
362
400
|
@property
|
|
363
401
|
def annotations(self) -> list[Annotation]:
|
|
364
|
-
"""All annotations in their **native frame** (
|
|
402
|
+
"""All annotations in their **native frame** (``annotation_frame_id``).
|
|
365
403
|
|
|
366
404
|
Use ``annotations_for_sample(token, frame=...)`` to request a specific frame.
|
|
367
405
|
"""
|
|
@@ -411,94 +449,54 @@ class ClipLoader:
|
|
|
411
449
|
def annotations_for_sample(
|
|
412
450
|
self,
|
|
413
451
|
sample_token: str,
|
|
414
|
-
frame: str =
|
|
452
|
+
frame: str | None = None,
|
|
415
453
|
) -> list[Annotation]:
|
|
416
|
-
"""All annotations for a given sample, transformed to
|
|
454
|
+
"""All annotations for a given sample, optionally transformed to ``frame``.
|
|
417
455
|
|
|
418
456
|
Args:
|
|
419
457
|
sample_token: The sample token.
|
|
420
|
-
frame: Target
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
- ``"sensor"`` is rejected (ambiguous — no single sensor implied).
|
|
458
|
+
frame: Target frame_id. ``None`` (default) returns annotations in
|
|
459
|
+
their stored frame (``loader.annotation_frame_id``). Any other
|
|
460
|
+
value must be a real frame_id in the calibration tree, or the
|
|
461
|
+
constant :data:`CLIP_WORLD_FRAME_ID`. The legacy magic aliases
|
|
462
|
+
``"sensor"``/``"ego"``/``"world"`` raise ``ValueError``.
|
|
426
463
|
|
|
427
464
|
Returns:
|
|
428
465
|
List of Annotation objects in the requested frame.
|
|
429
466
|
"""
|
|
430
|
-
if frame
|
|
431
|
-
|
|
432
|
-
"frame='sensor' is ambiguous for annotations; pass an explicit "
|
|
433
|
-
"frame_id (e.g. 'LIDAR_TOP') or use 'ego' / 'base_link' / 'world'."
|
|
434
|
-
)
|
|
467
|
+
if frame is not None:
|
|
468
|
+
_reject_magic_frame(frame)
|
|
435
469
|
|
|
436
470
|
anns = self._ann_by_sample.get(sample_token, [])
|
|
437
|
-
if not anns:
|
|
471
|
+
if not anns or frame is None or frame == self.annotation_frame_id:
|
|
438
472
|
return anns
|
|
439
473
|
|
|
440
|
-
|
|
441
|
-
if
|
|
442
|
-
|
|
474
|
+
sd = None
|
|
475
|
+
if frame == CLIP_WORLD_FRAME_ID:
|
|
476
|
+
sd = self._representative_sd_for_sample(sample_token)
|
|
477
|
+
T = self.get_transform(self.annotation_frame_id, frame, sd=sd)
|
|
443
478
|
return [self._transform_annotation(a, T) for a in anns]
|
|
444
479
|
|
|
445
|
-
def _transform_for_annotation_target(
|
|
446
|
-
self, sample_token: str, target_frame: str
|
|
447
|
-
) -> Transform | None:
|
|
448
|
-
"""Build the transform from the clip's annotation frame to ``target_frame``.
|
|
449
|
-
|
|
450
|
-
Returns ``None`` when the requested frame matches the storage frame
|
|
451
|
-
(no transform needed).
|
|
452
|
-
"""
|
|
453
|
-
ann_frame = self.annotation_frame_id # e.g. "base_link" or "LIDAR_TOP"
|
|
454
|
-
if target_frame == ann_frame:
|
|
455
|
-
return None
|
|
456
|
-
if target_frame in _EGO_ALIASES and ann_frame in _EGO_ALIASES:
|
|
457
|
-
return None
|
|
458
|
-
|
|
459
|
-
chain = self._get_transform_chain()
|
|
460
|
-
# T(ego ← annotation_frame): identity if annotation frame is ego/base_link;
|
|
461
|
-
# else look up the sensor whose frame_id == ann_frame.
|
|
462
|
-
if ann_frame in _EGO_ALIASES:
|
|
463
|
-
ego_from_ann = Transform.identity()
|
|
464
|
-
else:
|
|
465
|
-
ego_from_ann = chain.get_ego_from_frame_id(ann_frame)
|
|
466
|
-
|
|
467
|
-
# T(target ← ego)
|
|
468
|
-
if target_frame in _EGO_ALIASES:
|
|
469
|
-
target_from_ego = Transform.identity()
|
|
470
|
-
elif target_frame == "world":
|
|
471
|
-
target_from_ego = self._get_world_from_ego_for_sample(sample_token)
|
|
472
|
-
else:
|
|
473
|
-
try:
|
|
474
|
-
ego_from_target = chain.get_ego_from_frame_id(target_frame)
|
|
475
|
-
except KeyError as e:
|
|
476
|
-
raise ValueError(
|
|
477
|
-
f"Invalid frame {target_frame!r} for annotations: {e}"
|
|
478
|
-
) from None
|
|
479
|
-
target_from_ego = ego_from_target.inverse
|
|
480
|
-
|
|
481
|
-
return target_from_ego @ ego_from_ann
|
|
482
|
-
|
|
483
480
|
def load_point_cloud(
|
|
484
481
|
self,
|
|
485
482
|
sd: SampleData,
|
|
486
|
-
frame:
|
|
483
|
+
frame: str | None = None,
|
|
487
484
|
) -> np.ndarray:
|
|
488
485
|
"""Load point cloud binary file as (N, C) float32 array.
|
|
489
486
|
|
|
490
487
|
Args:
|
|
491
488
|
sd: The SampleData record for the point cloud.
|
|
492
|
-
frame: Target
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
489
|
+
frame: Target frame_id. ``None`` (default) returns raw sensor
|
|
490
|
+
coordinates. Any other value must be a real frame_id in the
|
|
491
|
+
calibration tree, or :data:`CLIP_WORLD_FRAME_ID`. The legacy
|
|
492
|
+
magic aliases ``"sensor"``/``"ego"``/``"world"`` raise
|
|
493
|
+
``ValueError``.
|
|
496
494
|
|
|
497
495
|
Returns:
|
|
498
496
|
Point cloud as (N, C) float32 array with xyz in the requested frame.
|
|
499
497
|
"""
|
|
500
|
-
if frame not
|
|
501
|
-
|
|
498
|
+
if frame is not None:
|
|
499
|
+
_reject_magic_frame(frame)
|
|
502
500
|
|
|
503
501
|
sensor_info = self._meta.sensors[sd.channel]
|
|
504
502
|
if sensor_info.point_format is None:
|
|
@@ -512,53 +510,49 @@ class ClipLoader:
|
|
|
512
510
|
raw = np.fromfile(str(path), dtype=np.float32)
|
|
513
511
|
pts = raw.reshape(-1, num_fields)
|
|
514
512
|
|
|
515
|
-
|
|
513
|
+
src_frame = self.sensor_frame_id(sd)
|
|
514
|
+
if frame is None or frame == src_frame:
|
|
516
515
|
return pts
|
|
517
516
|
|
|
518
|
-
T = self.get_transform(
|
|
517
|
+
T = self.get_transform(src_frame, frame, sd=sd)
|
|
519
518
|
pts[:, :3] = T.apply(pts[:, :3].astype(np.float64)).astype(np.float32)
|
|
520
519
|
return pts
|
|
521
520
|
|
|
522
521
|
def get_transform(
|
|
523
522
|
self,
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
523
|
+
from_frame: str,
|
|
524
|
+
to_frame: str,
|
|
525
|
+
*,
|
|
526
|
+
sd: SampleData | None = None,
|
|
527
527
|
) -> Transform:
|
|
528
|
-
"""
|
|
528
|
+
"""Build the SE(3) transform ``to_frame ← from_frame``.
|
|
529
|
+
|
|
530
|
+
Both arguments are real frame_id strings (calibration-tree nodes or
|
|
531
|
+
:data:`CLIP_WORLD_FRAME_ID`). When either side is ``CLIP_WORLD_FRAME_ID``
|
|
532
|
+
the transform is dynamic and ``sd`` (or another ego_pose-bearing
|
|
533
|
+
SampleData) must be supplied to fix the timestamp.
|
|
529
534
|
|
|
530
535
|
Args:
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
536
|
+
from_frame: Source frame_id.
|
|
537
|
+
to_frame: Destination frame_id.
|
|
538
|
+
sd: SampleData fixing the ego_pose for ``CLIP_WORLD_FRAME_ID``;
|
|
539
|
+
omit when both frames are static (in the calibration tree).
|
|
534
540
|
|
|
535
541
|
Returns:
|
|
536
|
-
Transform
|
|
537
|
-
"""
|
|
538
|
-
if from_frame not in _VALID_FRAMES:
|
|
539
|
-
raise ValueError(f"Invalid from_frame '{from_frame}', must be one of {_VALID_FRAMES}")
|
|
540
|
-
if to_frame not in _VALID_FRAMES:
|
|
541
|
-
raise ValueError(f"Invalid to_frame '{to_frame}', must be one of {_VALID_FRAMES}")
|
|
542
|
+
Transform such that ``p_to = T.apply(p_from)``.
|
|
542
543
|
|
|
544
|
+
Raises:
|
|
545
|
+
ValueError: if a magic alias is passed, or ``CLIP_WORLD_FRAME_ID``
|
|
546
|
+
is requested without ``sd``.
|
|
547
|
+
KeyError: if a frame_id is not in the calibration tree.
|
|
548
|
+
"""
|
|
549
|
+
_reject_magic_frame(from_frame)
|
|
550
|
+
_reject_magic_frame(to_frame)
|
|
543
551
|
if from_frame == to_frame:
|
|
544
552
|
return Transform.identity()
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
world_from_ego = chain.get_world_from_ego(sd.ego_pose_token)
|
|
549
|
-
|
|
550
|
-
if from_frame == "sensor" and to_frame == "ego":
|
|
551
|
-
return ego_from_sensor
|
|
552
|
-
elif from_frame == "sensor" and to_frame == "world":
|
|
553
|
-
return world_from_ego @ ego_from_sensor
|
|
554
|
-
elif from_frame == "ego" and to_frame == "world":
|
|
555
|
-
return world_from_ego
|
|
556
|
-
elif from_frame == "ego" and to_frame == "sensor":
|
|
557
|
-
return ego_from_sensor.inverse
|
|
558
|
-
elif from_frame == "world" and to_frame == "ego":
|
|
559
|
-
return world_from_ego.inverse
|
|
560
|
-
else: # world -> sensor
|
|
561
|
-
return (world_from_ego @ ego_from_sensor).inverse
|
|
553
|
+
T_root_from_src = self._root_from_frame(from_frame, sd=sd)
|
|
554
|
+
T_root_from_dst = self._root_from_frame(to_frame, sd=sd)
|
|
555
|
+
return T_root_from_dst.inverse @ T_root_from_src
|
|
562
556
|
|
|
563
557
|
def load_image_path(self, sd: SampleData) -> Path:
|
|
564
558
|
"""Get the absolute path to an image file.
|
|
@@ -581,24 +575,42 @@ class ClipLoader:
|
|
|
581
575
|
self._transform_chain = TransformChain(self)
|
|
582
576
|
return self._transform_chain
|
|
583
577
|
|
|
584
|
-
def
|
|
585
|
-
|
|
578
|
+
def _root_from_frame(
|
|
579
|
+
self, frame_id: str, *, sd: SampleData | None = None,
|
|
580
|
+
) -> Transform:
|
|
581
|
+
"""Transform from ``frame_id`` to the calibration tree root.
|
|
586
582
|
|
|
587
|
-
|
|
588
|
-
|
|
583
|
+
For :data:`CLIP_WORLD_FRAME_ID` the result is dynamic (depends on
|
|
584
|
+
``sd.ego_pose_token``); for any other frame it is the static walk
|
|
585
|
+
through ``calibrated_sensor.json``.
|
|
589
586
|
"""
|
|
587
|
+
chain = self._get_transform_chain()
|
|
588
|
+
if frame_id != CLIP_WORLD_FRAME_ID:
|
|
589
|
+
return chain.get_root_from_frame_id(frame_id)
|
|
590
|
+
if sd is None:
|
|
591
|
+
raise ValueError(
|
|
592
|
+
f"frame {CLIP_WORLD_FRAME_ID!r} is dynamic — pass sd=<SampleData> "
|
|
593
|
+
f"so the transform can be evaluated at the correct ego_pose."
|
|
594
|
+
)
|
|
595
|
+
ego_fid = self.ego_pose_frame_id
|
|
596
|
+
T_root_from_ego = chain.get_root_from_frame_id(ego_fid)
|
|
597
|
+
T_world_from_ego = chain.get_world_from_ego(sd.ego_pose_token)
|
|
598
|
+
# T_root_from_world = T_root_from_ego @ T_world_from_ego.inverse
|
|
599
|
+
return T_root_from_ego @ T_world_from_ego.inverse
|
|
600
|
+
|
|
601
|
+
def _representative_sd_for_sample(self, sample_token: str) -> SampleData:
|
|
602
|
+
"""Pick a representative SampleData (preferring lidar) for a sample."""
|
|
590
603
|
sds = self._sd_by_sample.get(sample_token, [])
|
|
591
604
|
if not sds:
|
|
592
|
-
raise ValueError(
|
|
593
|
-
|
|
594
|
-
|
|
605
|
+
raise ValueError(
|
|
606
|
+
f"sample {sample_token!r} has no sample_data — cannot resolve "
|
|
607
|
+
f"{CLIP_WORLD_FRAME_ID!r}."
|
|
608
|
+
)
|
|
595
609
|
cs_map = {cs.channel: cs for cs in self._calibrated_sensors}
|
|
596
|
-
|
|
610
|
+
return next(
|
|
597
611
|
(s for s in sds if cs_map.get(s.channel) and cs_map[s.channel].modality == "lidar"),
|
|
598
612
|
sds[0],
|
|
599
613
|
)
|
|
600
|
-
chain = self._get_transform_chain()
|
|
601
|
-
return chain.get_world_from_ego(sd.ego_pose_token)
|
|
602
614
|
|
|
603
615
|
@staticmethod
|
|
604
616
|
def _transform_annotation(ann: Annotation, T: Transform) -> Annotation:
|
|
@@ -275,7 +275,7 @@ class Annotation:
|
|
|
275
275
|
via calibration, so per-sample timing is determined by ``sample_token``.
|
|
276
276
|
For v0.5 clips without an explicit field, the convention is per-sample ego
|
|
277
277
|
frame (== ``base_link``). Use ``ClipLoader.annotations_for_sample(..., frame=...)``
|
|
278
|
-
to transform to ``"
|
|
278
|
+
to transform to ``"clip_world"`` or any other frame_id in the calibration tree.
|
|
279
279
|
|
|
280
280
|
Attributes:
|
|
281
281
|
token: Unique annotation token.
|
|
@@ -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
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|