x4d-devkit 0.3.0__tar.gz → 0.5.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.3.0/x4d_devkit.egg-info → x4d_devkit-0.5.0}/PKG-INFO +1 -1
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/pyproject.toml +1 -1
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/tests/test_coordinate_frames.py +37 -32
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/tests/test_models.py +8 -4
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/tests/test_nuscenes_converter.py +8 -7
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/tests/test_validation.py +3 -3
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/converters/nuscenes.py +65 -26
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/core/loader.py +20 -14
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/core/models.py +19 -11
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/eval/detection.py +9 -16
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/validation/validator.py +13 -0
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0/x4d_devkit.egg-info}/PKG-INFO +1 -1
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/LICENSE +0 -0
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/README.md +0 -0
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/setup.cfg +0 -0
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/tests/test_detection_eval.py +0 -0
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/tests/test_fusion.py +0 -0
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/tests/test_loader.py +0 -0
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/tests/test_matching.py +0 -0
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/tests/test_metrics.py +0 -0
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/tests/test_token.py +0 -0
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/tests/test_transform.py +0 -0
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/tests/test_transform_chain.py +0 -0
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/__init__.py +0 -0
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/cli.py +0 -0
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/client/__init__.py +0 -0
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/client/checkpoints.py +0 -0
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/client/client.py +0 -0
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/client/clips.py +0 -0
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/client/projects.py +0 -0
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/client/test_sets.py +0 -0
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/converters/__init__.py +0 -0
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/core/__init__.py +0 -0
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/core/token.py +0 -0
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/core/transform.py +0 -0
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/eval/__init__.py +0 -0
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/eval/matching.py +0 -0
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/eval/metrics.py +0 -0
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/fusion.py +0 -0
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/validation/__init__.py +0 -0
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/validation/report.py +0 -0
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit.egg-info/SOURCES.txt +0 -0
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit.egg-info/dependency_links.txt +0 -0
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit.egg-info/entry_points.txt +0 -0
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit.egg-info/requires.txt +0 -0
- {x4d_devkit-0.3.0 → x4d_devkit-0.5.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.5.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"
|
|
@@ -56,14 +56,18 @@ def _make_synthetic_clip(tmp_path: Path) -> Path:
|
|
|
56
56
|
"channel": "LIDAR_TOP",
|
|
57
57
|
"translation": {"x": 0.0, "y": 0.0, "z": 1.0},
|
|
58
58
|
"rotation": {"qx": 0.0, "qy": 0.0, "qz": 0.0, "qw": 1.0},
|
|
59
|
-
"
|
|
59
|
+
"intrinsics": None,
|
|
60
|
+
"distortion": None,
|
|
60
61
|
},
|
|
61
62
|
{
|
|
62
63
|
"calibrated_sensor_token": "cs-cam",
|
|
63
64
|
"channel": "CAM_FRONT",
|
|
64
65
|
"translation": {"x": 0.5, "y": 0.0, "z": 0.8},
|
|
65
66
|
"rotation": {"qx": 0.0, "qy": 0.0, "qz": 0.0, "qw": 1.0},
|
|
66
|
-
"
|
|
67
|
+
"intrinsics": {"camera_model": "pinhole",
|
|
68
|
+
"fx": 1000, "fy": 1000, "cx": 320, "cy": 240,
|
|
69
|
+
"image_width": 640, "image_height": 480},
|
|
70
|
+
"distortion": None,
|
|
67
71
|
},
|
|
68
72
|
]))
|
|
69
73
|
|
|
@@ -150,7 +154,9 @@ def _make_synthetic_clip(tmp_path: Path) -> Path:
|
|
|
150
154
|
pts.tofile(str(lidar_dir / "frame0.bin"))
|
|
151
155
|
pts.tofile(str(lidar_dir / "frame1.bin"))
|
|
152
156
|
|
|
153
|
-
# -- annotation.json --
|
|
157
|
+
# -- annotation.json (v0.5: per-sample ego frame) --
|
|
158
|
+
# s-0 ego at origin; s-1 ego at (10, 0, 0). Same world pos (5,3,0) and (10,3,0)
|
|
159
|
+
# for the two frames maps to ego (5,3,0) and (0,3,0) respectively.
|
|
154
160
|
(clip_dir / "annotation.json").write_text(json.dumps([
|
|
155
161
|
{
|
|
156
162
|
"annotation_token": "ann-0",
|
|
@@ -176,7 +182,7 @@ def _make_synthetic_clip(tmp_path: Path) -> Path:
|
|
|
176
182
|
"instance_token": "inst-0",
|
|
177
183
|
"category": "car",
|
|
178
184
|
"bbox_3d": {
|
|
179
|
-
"translation": {"x":
|
|
185
|
+
"translation": {"x": 0.0, "y": 3.0, "z": 0.0},
|
|
180
186
|
"size": {"length": 4.5, "width": 2.0, "height": 1.5},
|
|
181
187
|
"rotation": {"qx": 0.0, "qy": 0.0, "qz": 0.0, "qw": 1.0},
|
|
182
188
|
},
|
|
@@ -273,54 +279,53 @@ class TestLoadPointCloudFrames:
|
|
|
273
279
|
# ---------------------------------------------------------------------------
|
|
274
280
|
|
|
275
281
|
class TestAnnotationsForSampleFrames:
|
|
276
|
-
def
|
|
277
|
-
"""Default frame should return annotations in
|
|
282
|
+
def test_ego_frame_default(self, loader):
|
|
283
|
+
"""Default frame should return annotations in per-sample ego coordinates (v0.5 native)."""
|
|
278
284
|
anns = loader.annotations_for_sample("s-0")
|
|
279
285
|
assert len(anns) == 1
|
|
280
286
|
np.testing.assert_allclose(anns[0].translation, [5.0, 3.0, 0.0])
|
|
281
287
|
|
|
282
|
-
def
|
|
283
|
-
"""Explicit frame='
|
|
288
|
+
def test_ego_frame_explicit(self, loader):
|
|
289
|
+
"""Explicit frame='ego' should match default."""
|
|
284
290
|
anns_default = loader.annotations_for_sample("s-0")
|
|
285
|
-
|
|
286
|
-
assert len(anns_default) == len(
|
|
287
|
-
np.testing.assert_allclose(anns_default[0].translation,
|
|
291
|
+
anns_ego = loader.annotations_for_sample("s-0", frame="ego")
|
|
292
|
+
assert len(anns_default) == len(anns_ego)
|
|
293
|
+
np.testing.assert_allclose(anns_default[0].translation, anns_ego[0].translation)
|
|
288
294
|
|
|
289
|
-
def
|
|
290
|
-
"""frame='
|
|
291
|
-
anns = loader.annotations_for_sample("s-0", frame="
|
|
292
|
-
# ego at origin ->
|
|
295
|
+
def test_world_frame_first_sample(self, loader):
|
|
296
|
+
"""frame='world' for first sample (ego at origin): same as ego."""
|
|
297
|
+
anns = loader.annotations_for_sample("s-0", frame="world")
|
|
298
|
+
# ego at origin -> ego-to-world is identity
|
|
293
299
|
np.testing.assert_allclose(anns[0].translation, [5.0, 3.0, 0.0], atol=1e-5)
|
|
294
300
|
|
|
295
|
-
def
|
|
296
|
-
"""frame='
|
|
297
|
-
anns = loader.annotations_for_sample("s-1", frame="
|
|
298
|
-
#
|
|
299
|
-
np.testing.assert_allclose(anns[0].translation, [
|
|
301
|
+
def test_world_frame_second_sample(self, loader):
|
|
302
|
+
"""frame='world' for second sample (ego at x=10): adds ego offset."""
|
|
303
|
+
anns = loader.annotations_for_sample("s-1", frame="world")
|
|
304
|
+
# ego frame (0,3,0), ego at (10,0,0) -> world (10,3,0)
|
|
305
|
+
np.testing.assert_allclose(anns[0].translation, [10.0, 3.0, 0.0], atol=1e-5)
|
|
300
306
|
|
|
301
|
-
def
|
|
302
|
-
"""
|
|
307
|
+
def test_ego_frame_velocity_unchanged(self, loader):
|
|
308
|
+
"""Native (ego) frame: velocity returned as stored."""
|
|
303
309
|
anns = loader.annotations_for_sample("s-0", frame="ego")
|
|
304
|
-
# ego rotation is identity, so velocity unchanged
|
|
305
310
|
np.testing.assert_allclose(anns[0].velocity, [5.0, 0.0, 0.0], atol=1e-5)
|
|
306
311
|
|
|
307
|
-
def
|
|
308
|
-
"""Non-pose fields should be preserved."""
|
|
309
|
-
anns_world = loader.annotations_for_sample("s-0", frame="world")
|
|
312
|
+
def test_world_frame_preserves_other_fields(self, loader):
|
|
313
|
+
"""Non-pose fields should be preserved across frame transforms."""
|
|
310
314
|
anns_ego = loader.annotations_for_sample("s-0", frame="ego")
|
|
315
|
+
anns_world = loader.annotations_for_sample("s-0", frame="world")
|
|
311
316
|
assert anns_ego[0].token == anns_world[0].token
|
|
312
317
|
assert anns_ego[0].category == anns_world[0].category
|
|
313
318
|
np.testing.assert_allclose(anns_ego[0].size, anns_world[0].size)
|
|
314
319
|
assert anns_ego[0].num_lidar_pts == anns_world[0].num_lidar_pts
|
|
315
320
|
|
|
316
|
-
def
|
|
321
|
+
def test_world_frame_returns_new_objects(self, loader):
|
|
317
322
|
"""Transformed annotations should be new objects, not mutated originals."""
|
|
318
|
-
anns_world = loader.annotations_for_sample("s-1", frame="world")
|
|
319
323
|
anns_ego = loader.annotations_for_sample("s-1", frame="ego")
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
+
anns_world = loader.annotations_for_sample("s-1", frame="world")
|
|
325
|
+
# Originals (ego, native) should be unmodified
|
|
326
|
+
np.testing.assert_allclose(anns_ego[0].translation, [0.0, 3.0, 0.0])
|
|
327
|
+
# World should be different
|
|
328
|
+
assert not np.allclose(anns_world[0].translation, anns_ego[0].translation, atol=0.1)
|
|
324
329
|
|
|
325
330
|
def test_sensor_frame_raises(self, loader):
|
|
326
331
|
with pytest.raises(ValueError, match="sensor frame"):
|
|
@@ -82,13 +82,17 @@ class TestSampleData:
|
|
|
82
82
|
class TestCalibratedSensor:
|
|
83
83
|
def test_construction(self):
|
|
84
84
|
cs = CalibratedSensor(
|
|
85
|
-
token="cs1", channel="
|
|
85
|
+
token="cs1", channel="cam_front", modality="camera",
|
|
86
86
|
translation=np.zeros(3), rotation=np.array([0, 0, 0, 1.0]),
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
intrinsics={"camera_model": "pinhole",
|
|
88
|
+
"fx": 1266.4, "fy": 1266.4, "cx": 816.3, "cy": 491.5,
|
|
89
|
+
"image_width": 1600, "image_height": 900},
|
|
90
|
+
distortion={"model": "plumb_bob",
|
|
91
|
+
"k1": 0.0, "k2": 0.0, "k3": 0.0, "p1": 0.0, "p2": 0.0},
|
|
89
92
|
)
|
|
90
93
|
assert cs.modality == "camera"
|
|
91
|
-
assert cs.
|
|
94
|
+
assert cs.intrinsics["fx"] == 1266.4
|
|
95
|
+
assert cs.distortion["model"] == "plumb_bob"
|
|
92
96
|
|
|
93
97
|
|
|
94
98
|
class TestAnnotation:
|
|
@@ -108,11 +108,10 @@ class TestIntrinsicConversion:
|
|
|
108
108
|
assert result["fy"] == pytest.approx(1266.4)
|
|
109
109
|
assert result["cx"] == pytest.approx(816.3)
|
|
110
110
|
assert result["cy"] == pytest.approx(491.5)
|
|
111
|
-
assert result["
|
|
112
|
-
|
|
113
|
-
assert result["
|
|
114
|
-
assert result["
|
|
115
|
-
assert result["p2"] == 0.0
|
|
111
|
+
assert result["camera_model"] == "pinhole"
|
|
112
|
+
# Image dimensions default to nuScenes standard 1600x900 when not given.
|
|
113
|
+
assert result["image_width"] == 1600
|
|
114
|
+
assert result["image_height"] == 900
|
|
116
115
|
|
|
117
116
|
def test_empty_intrinsic(self):
|
|
118
117
|
"""nuScenes uses empty list for non-camera sensors."""
|
|
@@ -185,8 +184,10 @@ class TestNuScenesConverterExtraction:
|
|
|
185
184
|
# Check named fields
|
|
186
185
|
assert "x" in cam_front["translation"]
|
|
187
186
|
assert "qx" in cam_front["rotation"]
|
|
188
|
-
assert cam_front["
|
|
189
|
-
assert "fx" in cam_front["
|
|
187
|
+
assert cam_front["intrinsics"] is not None
|
|
188
|
+
assert "fx" in cam_front["intrinsics"]
|
|
189
|
+
assert cam_front["intrinsics"]["camera_model"] == "pinhole"
|
|
190
|
+
assert "child_frame" not in cam_front # dropped in v0.4
|
|
190
191
|
|
|
191
192
|
def test_ego_poses_local_coordinates(self, converter):
|
|
192
193
|
scene = converter.nusc.scene[0]
|
|
@@ -55,12 +55,12 @@ def valid_clip(tmp_path):
|
|
|
55
55
|
[
|
|
56
56
|
{
|
|
57
57
|
"calibrated_sensor_token": "cs_lidar",
|
|
58
|
-
"channel": "
|
|
58
|
+
"channel": "lidar_top",
|
|
59
59
|
"parent_frame": "base_link",
|
|
60
|
-
"child_frame": "lidar_top",
|
|
61
60
|
"translation": {"x": 0, "y": 0, "z": 1.8},
|
|
62
61
|
"rotation": {"qx": 0, "qy": 0, "qz": 0, "qw": 1},
|
|
63
|
-
"
|
|
62
|
+
"intrinsics": None,
|
|
63
|
+
"distortion": None,
|
|
64
64
|
}
|
|
65
65
|
]
|
|
66
66
|
)
|
|
@@ -32,20 +32,26 @@ def size_wlh_to_named(size: list[float]) -> dict[str, float]:
|
|
|
32
32
|
return {"length": size[1], "width": size[0], "height": size[2]}
|
|
33
33
|
|
|
34
34
|
|
|
35
|
-
def intrinsic_matrix_to_named(
|
|
36
|
-
|
|
35
|
+
def intrinsic_matrix_to_named(
|
|
36
|
+
matrix: list[list[float]],
|
|
37
|
+
image_width: int | None = None,
|
|
38
|
+
image_height: int | None = None,
|
|
39
|
+
) -> dict[str, float] | None:
|
|
40
|
+
"""Convert 3x3 intrinsic matrix to X4D v0.4 intrinsics dict. Returns None for empty.
|
|
41
|
+
|
|
42
|
+
nuScenes does not record image dimensions per-sensor; they default to the
|
|
43
|
+
standard 1600x900 used by the dataset when not provided.
|
|
44
|
+
"""
|
|
37
45
|
if not matrix:
|
|
38
46
|
return None
|
|
39
47
|
return {
|
|
48
|
+
"camera_model": "pinhole",
|
|
40
49
|
"fx": matrix[0][0],
|
|
41
50
|
"fy": matrix[1][1],
|
|
42
51
|
"cx": matrix[0][2],
|
|
43
52
|
"cy": matrix[1][2],
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"k3": 0.0,
|
|
47
|
-
"p1": 0.0,
|
|
48
|
-
"p2": 0.0,
|
|
53
|
+
"image_width": int(image_width) if image_width is not None else 1600,
|
|
54
|
+
"image_height": int(image_height) if image_height is not None else 900,
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
|
|
@@ -238,21 +244,22 @@ class NuScenesConverter:
|
|
|
238
244
|
sensor = self._sensor_by_token[cs["sensor_token"]]
|
|
239
245
|
|
|
240
246
|
parent_frame = "base_link"
|
|
241
|
-
|
|
247
|
+
|
|
248
|
+
intrinsics = (
|
|
249
|
+
intrinsic_matrix_to_named(cs["camera_intrinsic"])
|
|
250
|
+
if sensor["modality"] == "camera"
|
|
251
|
+
else None
|
|
252
|
+
)
|
|
242
253
|
|
|
243
254
|
records.append(
|
|
244
255
|
{
|
|
245
256
|
"calibrated_sensor_token": generate_token(clip_id, f"cs:{channel}"),
|
|
246
257
|
"channel": channel,
|
|
247
258
|
"parent_frame": parent_frame,
|
|
248
|
-
"child_frame": child_frame,
|
|
249
259
|
"translation": translation_to_named(cs["translation"]),
|
|
250
260
|
"rotation": quat_wxyz_to_named(cs["rotation"]),
|
|
251
|
-
"
|
|
252
|
-
|
|
253
|
-
)
|
|
254
|
-
if sensor["modality"] == "camera"
|
|
255
|
-
else None,
|
|
261
|
+
"intrinsics": intrinsics,
|
|
262
|
+
"distortion": None,
|
|
256
263
|
}
|
|
257
264
|
)
|
|
258
265
|
return records
|
|
@@ -373,13 +380,36 @@ class NuScenesConverter:
|
|
|
373
380
|
return None
|
|
374
381
|
return {"vx": float(vel[0]), "vy": float(vel[1]), "vz": float(vel[2])}
|
|
375
382
|
|
|
383
|
+
def _get_lidar_ego_pose_for_sample(self, sample: dict) -> tuple[np.ndarray, list]:
|
|
384
|
+
"""Return (R_global, t_global) of the LIDAR_TOP keyframe ego_pose for a sample.
|
|
385
|
+
|
|
386
|
+
v0.5 convention: the ego frame for an annotation = the ego_pose of the
|
|
387
|
+
sample's LiDAR keyframe sample_data.
|
|
388
|
+
"""
|
|
389
|
+
lidar_sd_token = sample["data"].get("LIDAR_TOP")
|
|
390
|
+
if lidar_sd_token is None:
|
|
391
|
+
# Fallback to whichever target-channel sd has the earliest ts.
|
|
392
|
+
earliest_ts = None
|
|
393
|
+
earliest_ep = None
|
|
394
|
+
for channel, sd_token in sample["data"].items():
|
|
395
|
+
if channel not in TARGET_CHANNELS:
|
|
396
|
+
continue
|
|
397
|
+
sd = self.nusc.get("sample_data", sd_token)
|
|
398
|
+
ep = self.nusc.get("ego_pose", sd["ego_pose_token"])
|
|
399
|
+
if earliest_ts is None or ep["timestamp"] < earliest_ts:
|
|
400
|
+
earliest_ts = ep["timestamp"]
|
|
401
|
+
earliest_ep = ep
|
|
402
|
+
ep = earliest_ep
|
|
403
|
+
else:
|
|
404
|
+
sd = self.nusc.get("sample_data", lidar_sd_token)
|
|
405
|
+
ep = self.nusc.get("ego_pose", sd["ego_pose_token"])
|
|
406
|
+
R = quat_to_rotation_matrix(ep["rotation"])
|
|
407
|
+
return R, ep["translation"]
|
|
408
|
+
|
|
376
409
|
def _extract_annotations(
|
|
377
410
|
self, scene: dict, clip_id: str
|
|
378
411
|
) -> tuple[list[dict], list[dict]]:
|
|
379
|
-
"""Extract annotations and instances for the scene, in
|
|
380
|
-
R_first, t_first = self._get_first_ego_pose_transform(scene)
|
|
381
|
-
R_inv, t_inv = invert_se3(R_first, t_first)
|
|
382
|
-
|
|
412
|
+
"""Extract annotations and instances for the scene, in **per-sample ego frame** (v0.5)."""
|
|
383
413
|
annotations: list[dict] = []
|
|
384
414
|
instance_anns: dict[str, list[dict]] = {}
|
|
385
415
|
|
|
@@ -396,6 +426,10 @@ class NuScenesConverter:
|
|
|
396
426
|
sample = self.nusc.get("sample", sample_token)
|
|
397
427
|
new_sample_token = sample_token_map[sample_token]
|
|
398
428
|
|
|
429
|
+
# Per-sample ego_pose (LIDAR_TOP keyframe) — annotation frame.
|
|
430
|
+
R_ego_global, t_ego_global = self._get_lidar_ego_pose_for_sample(sample)
|
|
431
|
+
R_inv, t_inv = invert_se3(R_ego_global, t_ego_global)
|
|
432
|
+
|
|
399
433
|
for ann_token in sample["anns"]:
|
|
400
434
|
ann = self.nusc.get("sample_annotation", ann_token)
|
|
401
435
|
nusc_instance_token = ann["instance_token"]
|
|
@@ -403,12 +437,13 @@ class NuScenesConverter:
|
|
|
403
437
|
self.nusc.get("instance", nusc_instance_token)["category_token"]
|
|
404
438
|
]["name"]
|
|
405
439
|
|
|
440
|
+
# Global → ego (this sample's ego)
|
|
406
441
|
t_global = np.asarray(ann["translation"])
|
|
407
|
-
|
|
442
|
+
t_ego = apply_se3(R_inv, t_inv, t_global)
|
|
408
443
|
|
|
409
444
|
R_ann_global = quat_to_rotation_matrix(ann["rotation"])
|
|
410
|
-
|
|
411
|
-
|
|
445
|
+
R_ann_ego = R_inv @ R_ann_global
|
|
446
|
+
q_ann_ego = rotation_matrix_to_quat(R_ann_ego)
|
|
412
447
|
|
|
413
448
|
attributes = [
|
|
414
449
|
self._attribute_by_token[at]["name"]
|
|
@@ -419,12 +454,16 @@ class NuScenesConverter:
|
|
|
419
454
|
int(ann["visibility_token"]) if ann["visibility_token"] else None
|
|
420
455
|
)
|
|
421
456
|
|
|
422
|
-
#
|
|
457
|
+
# Velocity: nuScenes computes globally; rotate into this sample's ego.
|
|
423
458
|
vel_global = self._compute_velocity(ann_token)
|
|
424
459
|
if vel_global is not None:
|
|
425
460
|
vel_vec = np.array([vel_global["vx"], vel_global["vy"], vel_global["vz"]])
|
|
426
|
-
|
|
427
|
-
velocity = {
|
|
461
|
+
vel_ego = R_inv @ vel_vec
|
|
462
|
+
velocity = {
|
|
463
|
+
"vx": float(vel_ego[0]),
|
|
464
|
+
"vy": float(vel_ego[1]),
|
|
465
|
+
"vz": float(vel_ego[2]),
|
|
466
|
+
}
|
|
428
467
|
else:
|
|
429
468
|
velocity = None
|
|
430
469
|
|
|
@@ -432,9 +471,9 @@ class NuScenesConverter:
|
|
|
432
471
|
"sample_token": new_sample_token,
|
|
433
472
|
"category": category,
|
|
434
473
|
"bbox_3d": {
|
|
435
|
-
"translation": translation_to_named(
|
|
474
|
+
"translation": translation_to_named(t_ego.tolist()),
|
|
436
475
|
"size": size_wlh_to_named(ann["size"]),
|
|
437
|
-
"rotation": quat_wxyz_to_named(
|
|
476
|
+
"rotation": quat_wxyz_to_named(q_ann_ego),
|
|
438
477
|
},
|
|
439
478
|
"num_lidar_pts": ann["num_lidar_pts"],
|
|
440
479
|
"visibility": visibility,
|
|
@@ -115,13 +115,15 @@ class ClipLoader:
|
|
|
115
115
|
for r in records:
|
|
116
116
|
t = r["translation"]
|
|
117
117
|
q = r["rotation"]
|
|
118
|
+
intr = r.get("intrinsics")
|
|
118
119
|
result.append(CalibratedSensor(
|
|
119
120
|
token=r["calibrated_sensor_token"],
|
|
120
121
|
channel=r["channel"],
|
|
121
|
-
modality="
|
|
122
|
+
modality="camera" if intr is not None else "lidar",
|
|
122
123
|
translation=[t["x"], t["y"], t["z"]],
|
|
123
124
|
rotation=[q["qx"], q["qy"], q["qz"], q["qw"]],
|
|
124
|
-
|
|
125
|
+
intrinsics=intr,
|
|
126
|
+
distortion=r.get("distortion"),
|
|
125
127
|
))
|
|
126
128
|
return result
|
|
127
129
|
|
|
@@ -248,7 +250,10 @@ class ClipLoader:
|
|
|
248
250
|
|
|
249
251
|
@property
|
|
250
252
|
def annotations(self) -> list[Annotation]:
|
|
251
|
-
"""All annotations in
|
|
253
|
+
"""All annotations in their **native frame** (ego since v0.5; world for v0.4 clips).
|
|
254
|
+
|
|
255
|
+
Use ``annotations_for_sample(token, frame=...)`` to request a specific frame.
|
|
256
|
+
"""
|
|
252
257
|
return self._annotations
|
|
253
258
|
|
|
254
259
|
@property
|
|
@@ -295,16 +300,17 @@ class ClipLoader:
|
|
|
295
300
|
def annotations_for_sample(
|
|
296
301
|
self,
|
|
297
302
|
sample_token: str,
|
|
298
|
-
frame: FrameType = "
|
|
303
|
+
frame: FrameType = "ego",
|
|
299
304
|
) -> list[Annotation]:
|
|
300
305
|
"""All annotations for a given sample.
|
|
301
306
|
|
|
302
307
|
Args:
|
|
303
308
|
sample_token: The sample token.
|
|
304
309
|
frame: Target coordinate frame.
|
|
305
|
-
- "
|
|
306
|
-
|
|
307
|
-
|
|
310
|
+
- "ego" (default, v0.5 native): no transform — annotations are
|
|
311
|
+
stored in the per-sample ego frame.
|
|
312
|
+
- "world": transform from ego to world using the ego_pose of
|
|
313
|
+
the LiDAR sample_data for this sample (or first sample_data
|
|
308
314
|
if no LiDAR).
|
|
309
315
|
|
|
310
316
|
Returns:
|
|
@@ -316,12 +322,12 @@ class ClipLoader:
|
|
|
316
322
|
raise ValueError("Annotations cannot be transformed to sensor frame")
|
|
317
323
|
|
|
318
324
|
anns = self._ann_by_sample.get(sample_token, [])
|
|
319
|
-
if frame == "
|
|
325
|
+
if frame == "ego" or not anns:
|
|
320
326
|
return anns
|
|
321
327
|
|
|
322
|
-
# frame == "
|
|
323
|
-
|
|
324
|
-
return [self._transform_annotation(a,
|
|
328
|
+
# frame == "world": transform per-sample-ego → world via ego_pose.
|
|
329
|
+
world_from_ego = self._get_world_from_ego_for_sample(sample_token)
|
|
330
|
+
return [self._transform_annotation(a, world_from_ego) for a in anns]
|
|
325
331
|
|
|
326
332
|
def load_point_cloud(
|
|
327
333
|
self,
|
|
@@ -420,8 +426,8 @@ class ClipLoader:
|
|
|
420
426
|
self._transform_chain = TransformChain(self)
|
|
421
427
|
return self._transform_chain
|
|
422
428
|
|
|
423
|
-
def
|
|
424
|
-
"""Get the
|
|
429
|
+
def _get_world_from_ego_for_sample(self, sample_token: str) -> Transform:
|
|
430
|
+
"""Get the world_from_ego transform for a sample.
|
|
425
431
|
|
|
426
432
|
Uses the ego_pose from the LiDAR sample_data, or the first sample_data
|
|
427
433
|
if no LiDAR channel exists.
|
|
@@ -437,7 +443,7 @@ class ClipLoader:
|
|
|
437
443
|
sds[0],
|
|
438
444
|
)
|
|
439
445
|
chain = self._get_transform_chain()
|
|
440
|
-
return chain.get_world_from_ego(sd.ego_pose_token)
|
|
446
|
+
return chain.get_world_from_ego(sd.ego_pose_token)
|
|
441
447
|
|
|
442
448
|
@staticmethod
|
|
443
449
|
def _transform_annotation(ann: Annotation, T: Transform) -> Annotation:
|
|
@@ -53,11 +53,14 @@ class CalibratedSensor:
|
|
|
53
53
|
|
|
54
54
|
Attributes:
|
|
55
55
|
token: Unique token for this calibrated sensor record.
|
|
56
|
-
channel: Sensor channel name
|
|
56
|
+
channel: Sensor channel name — equals the ROS frame_id of the sensor
|
|
57
|
+
(e.g. "lidar_top", "cam_front_left").
|
|
57
58
|
modality: "lidar" or "camera".
|
|
58
59
|
translation: (3,) float64 array — sensor position in ego frame [x, y, z] meters.
|
|
59
60
|
rotation: (4,) float64 array — sensor orientation as quaternion [qx, qy, qz, qw].
|
|
60
|
-
|
|
61
|
+
intrinsics: Full camera intrinsics dict (camera_model, fx, fy, cx, cy,
|
|
62
|
+
image_width, image_height) or None for LiDAR.
|
|
63
|
+
distortion: Distortion model dict (model + coefficients) or None.
|
|
61
64
|
"""
|
|
62
65
|
|
|
63
66
|
token: str
|
|
@@ -65,7 +68,8 @@ class CalibratedSensor:
|
|
|
65
68
|
modality: str
|
|
66
69
|
translation: np.ndarray
|
|
67
70
|
rotation: np.ndarray
|
|
68
|
-
|
|
71
|
+
intrinsics: dict | None
|
|
72
|
+
distortion: dict | None
|
|
69
73
|
|
|
70
74
|
def __post_init__(self) -> None:
|
|
71
75
|
object.__setattr__(self, "translation", np.asarray(self.translation, dtype=np.float64))
|
|
@@ -150,23 +154,27 @@ class SampleData:
|
|
|
150
154
|
|
|
151
155
|
@dataclass(frozen=True)
|
|
152
156
|
class Annotation:
|
|
153
|
-
"""A 3D bounding box annotation in
|
|
157
|
+
"""A 3D bounding box annotation in **per-sample ego frame** (v0.5).
|
|
154
158
|
|
|
155
|
-
Annotations are natively stored in the
|
|
156
|
-
(
|
|
157
|
-
to transform to
|
|
159
|
+
Annotations are natively stored in the ego frame of the sample they belong
|
|
160
|
+
to (the ego_pose of the sample's LiDAR keyframe). Use
|
|
161
|
+
``ClipLoader.annotations_for_sample(..., frame="world")`` to transform to
|
|
162
|
+
the clip's local world frame when needed (rare — typically only for
|
|
163
|
+
cross-frame trajectory visualization).
|
|
158
164
|
|
|
159
165
|
Attributes:
|
|
160
166
|
token: Unique annotation token.
|
|
161
167
|
sample_token: Token of the sample this annotation belongs to.
|
|
162
168
|
instance_token: Token of the tracked object instance.
|
|
163
169
|
category: Object category string (e.g. "car", "pedestrian").
|
|
164
|
-
translation: (3,) float64 array — box center [x, y, z] in
|
|
170
|
+
translation: (3,) float64 array — box center [x, y, z] in this sample's ego frame.
|
|
165
171
|
size: (3,) float64 array — box dimensions [length, width, height] in meters.
|
|
166
|
-
rotation: (4,) float64 array — box orientation as quaternion [qx, qy, qz, qw]
|
|
167
|
-
|
|
172
|
+
rotation: (4,) float64 array — box orientation as quaternion [qx, qy, qz, qw],
|
|
173
|
+
relative to this sample's ego frame.
|
|
174
|
+
num_lidar_pts: Number of LiDAR points inside this box (real-counted, ≥ 0).
|
|
168
175
|
visibility: Visibility level as string ("1" to "4", or "" if unknown).
|
|
169
|
-
velocity: (3,) float64 array — velocity [vx, vy, vz] in m/s,
|
|
176
|
+
velocity: (3,) float64 array — velocity [vx, vy, vz] in m/s, **ego frame**
|
|
177
|
+
(forward = +x, left = +y), or None.
|
|
170
178
|
attributes: List of attribute strings (e.g. ["vehicle.moving"]).
|
|
171
179
|
prev: Token of previous annotation of same instance, or "" if first.
|
|
172
180
|
next: Token of next annotation of same instance, or "" if last.
|
|
@@ -223,33 +223,26 @@ def _extract_boxes(
|
|
|
223
223
|
config: DetectionConfig,
|
|
224
224
|
max_boxes: int | None = None,
|
|
225
225
|
) -> dict[str, list[dict]]:
|
|
226
|
-
"""Extract annotation boxes grouped by sample_token, with distance filtering.
|
|
227
|
-
|
|
226
|
+
"""Extract annotation boxes grouped by sample_token, with distance filtering.
|
|
227
|
+
|
|
228
|
+
v0.5: annotations are in per-sample ego frame, so distance from ego is
|
|
229
|
+
simply ``||ann.translation[:2]||`` — no subtraction of ego_pose required.
|
|
230
|
+
"""
|
|
228
231
|
category_map = config.category_map or {}
|
|
229
232
|
class_names = config.class_names
|
|
230
233
|
class_range = config.class_range
|
|
231
234
|
|
|
232
|
-
# Map sample_token → ego position (for distance filtering)
|
|
233
|
-
sample_ego_pos: dict[str, np.ndarray] = {}
|
|
234
|
-
for sd in clip.sample_data:
|
|
235
|
-
if sd.sample_token not in sample_ego_pos and sd.is_keyframe:
|
|
236
|
-
ep = ep_by_token.get(sd.ego_pose_token)
|
|
237
|
-
if ep is not None:
|
|
238
|
-
sample_ego_pos[sd.sample_token] = ep.translation
|
|
239
|
-
|
|
240
235
|
result: dict[str, list[dict]] = {}
|
|
241
236
|
for sample in clip.samples:
|
|
242
|
-
|
|
243
|
-
anns = clip.annotations_for_sample(sample.token)
|
|
237
|
+
anns = clip.annotations_for_sample(sample.token) # default = ego in v0.5
|
|
244
238
|
boxes = []
|
|
245
239
|
for ann in anns:
|
|
246
240
|
det_name = map_category(ann.category, category_map, class_names)
|
|
247
241
|
if det_name is None or det_name not in class_range:
|
|
248
242
|
continue
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
continue
|
|
243
|
+
dist = float(np.linalg.norm(ann.translation[:2]))
|
|
244
|
+
if dist > class_range[det_name]:
|
|
245
|
+
continue
|
|
253
246
|
boxes.append({
|
|
254
247
|
"translation": ann.translation,
|
|
255
248
|
"size": ann.size,
|
|
@@ -218,6 +218,19 @@ def validate_clip(clip_dir: str | Path) -> ValidationReport:
|
|
|
218
218
|
)
|
|
219
219
|
break
|
|
220
220
|
|
|
221
|
+
# num_lidar_pts must be a non-negative integer (v0.5+: real-computed,
|
|
222
|
+
# 0 means "no points in box", not a default placeholder).
|
|
223
|
+
npts = ann.get("num_lidar_pts")
|
|
224
|
+
if not isinstance(npts, int) or isinstance(npts, bool) or npts < 0:
|
|
225
|
+
issues.append(
|
|
226
|
+
ValidationIssue(
|
|
227
|
+
"annotation_num_lidar_pts",
|
|
228
|
+
"error",
|
|
229
|
+
f"Invalid num_lidar_pts={npts!r} (must be int >= 0)",
|
|
230
|
+
ann.get("annotation_token", ""),
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
|
|
221
234
|
# Sample token reference
|
|
222
235
|
if ann.get("sample_token") not in sample_tokens:
|
|
223
236
|
issues.append(
|
|
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
|