x4d-devkit 0.2.2__tar.gz → 0.4.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.2.2/x4d_devkit.egg-info → x4d_devkit-0.4.0}/PKG-INFO +1 -1
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/pyproject.toml +1 -1
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/tests/test_coordinate_frames.py +6 -2
- x4d_devkit-0.4.0/tests/test_fusion.py +134 -0
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/tests/test_models.py +8 -4
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/tests/test_nuscenes_converter.py +8 -7
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/tests/test_validation.py +3 -3
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/x4d_devkit/__init__.py +4 -0
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/x4d_devkit/converters/nuscenes.py +21 -14
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/x4d_devkit/core/loader.py +4 -2
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/x4d_devkit/core/models.py +7 -3
- x4d_devkit-0.4.0/x4d_devkit/fusion.py +104 -0
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0/x4d_devkit.egg-info}/PKG-INFO +1 -1
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/x4d_devkit.egg-info/SOURCES.txt +2 -0
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/LICENSE +0 -0
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/README.md +0 -0
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/setup.cfg +0 -0
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/tests/test_detection_eval.py +0 -0
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/tests/test_loader.py +0 -0
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/tests/test_matching.py +0 -0
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/tests/test_metrics.py +0 -0
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/tests/test_token.py +0 -0
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/tests/test_transform.py +0 -0
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/tests/test_transform_chain.py +0 -0
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/x4d_devkit/cli.py +0 -0
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/x4d_devkit/client/__init__.py +0 -0
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/x4d_devkit/client/checkpoints.py +0 -0
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/x4d_devkit/client/client.py +0 -0
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/x4d_devkit/client/clips.py +0 -0
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/x4d_devkit/client/projects.py +0 -0
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/x4d_devkit/client/test_sets.py +0 -0
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/x4d_devkit/converters/__init__.py +0 -0
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/x4d_devkit/core/__init__.py +0 -0
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/x4d_devkit/core/token.py +0 -0
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/x4d_devkit/core/transform.py +0 -0
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/x4d_devkit/eval/__init__.py +0 -0
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/x4d_devkit/eval/detection.py +0 -0
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/x4d_devkit/eval/matching.py +0 -0
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/x4d_devkit/eval/metrics.py +0 -0
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/x4d_devkit/validation/__init__.py +0 -0
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/x4d_devkit/validation/report.py +0 -0
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/x4d_devkit/validation/validator.py +0 -0
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/x4d_devkit.egg-info/dependency_links.txt +0 -0
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/x4d_devkit.egg-info/entry_points.txt +0 -0
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.0}/x4d_devkit.egg-info/requires.txt +0 -0
- {x4d_devkit-0.2.2 → x4d_devkit-0.4.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.4.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
|
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from x4d_devkit.fusion import (
|
|
5
|
+
LIDAR_FUSED_DTYPE,
|
|
6
|
+
FusionSource,
|
|
7
|
+
fuse_pointclouds,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _identity_pose():
|
|
12
|
+
m = np.eye(4, dtype=np.float64)
|
|
13
|
+
return m
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_fused_dtype_is_5_float32_fields():
|
|
17
|
+
assert LIDAR_FUSED_DTYPE.itemsize == 20
|
|
18
|
+
assert LIDAR_FUSED_DTYPE.names == ("x", "y", "z", "intensity", "t")
|
|
19
|
+
for name in LIDAR_FUSED_DTYPE.names:
|
|
20
|
+
assert LIDAR_FUSED_DTYPE.fields[name][0] == np.dtype("<f4")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_single_lidar_identity_pose_no_per_point_ts():
|
|
24
|
+
pts = np.array(
|
|
25
|
+
[(1.0, 2.0, 3.0, 0.5),
|
|
26
|
+
(4.0, 5.0, 6.0, 0.9)],
|
|
27
|
+
dtype=[("x", "<f4"), ("y", "<f4"), ("z", "<f4"), ("intensity", "<f4")],
|
|
28
|
+
)
|
|
29
|
+
out = fuse_pointclouds(
|
|
30
|
+
sources=[FusionSource(points=pts, lidar_to_ego=_identity_pose(), per_point_ts_field=None)],
|
|
31
|
+
frame_ref_time_us=0,
|
|
32
|
+
)
|
|
33
|
+
assert out.dtype == LIDAR_FUSED_DTYPE
|
|
34
|
+
assert out.shape == (2,)
|
|
35
|
+
np.testing.assert_array_equal(out["x"], [1.0, 4.0])
|
|
36
|
+
np.testing.assert_allclose(out["intensity"], [0.5, 0.9], rtol=1e-6)
|
|
37
|
+
np.testing.assert_array_equal(out["t"], [0.0, 0.0])
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_multi_lidar_concat_with_per_lidar_pose():
|
|
41
|
+
pts_a = np.array([(1.0, 0.0, 0.0, 0.1)],
|
|
42
|
+
dtype=[("x", "<f4"), ("y", "<f4"), ("z", "<f4"), ("intensity", "<f4")])
|
|
43
|
+
pts_b = np.array([(0.0, 1.0, 0.0, 0.2)],
|
|
44
|
+
dtype=[("x", "<f4"), ("y", "<f4"), ("z", "<f4"), ("intensity", "<f4")])
|
|
45
|
+
pose_b = np.eye(4, dtype=np.float64); pose_b[0, 3] = 5.0 # shift +5 in x
|
|
46
|
+
out = fuse_pointclouds(
|
|
47
|
+
sources=[
|
|
48
|
+
FusionSource(points=pts_a, lidar_to_ego=_identity_pose(), per_point_ts_field=None),
|
|
49
|
+
FusionSource(points=pts_b, lidar_to_ego=pose_b, per_point_ts_field=None),
|
|
50
|
+
],
|
|
51
|
+
frame_ref_time_us=0,
|
|
52
|
+
)
|
|
53
|
+
assert out.shape == (2,)
|
|
54
|
+
np.testing.assert_allclose(out["x"], [1.0, 5.0])
|
|
55
|
+
np.testing.assert_allclose(out["y"], [0.0, 1.0])
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_per_point_ts_offset_is_seconds_relative_to_frame_ref():
|
|
59
|
+
# Points at +1ms and +2ms vs frame ref
|
|
60
|
+
pts = np.array(
|
|
61
|
+
[(1.0, 0.0, 0.0, 0.0, 1_000_000_000),
|
|
62
|
+
(2.0, 0.0, 0.0, 0.0, 1_000_001_000)],
|
|
63
|
+
dtype=[("x", "<f4"), ("y", "<f4"), ("z", "<f4"), ("intensity", "<f4"), ("timestamp", "<u8")],
|
|
64
|
+
)
|
|
65
|
+
out = fuse_pointclouds(
|
|
66
|
+
sources=[FusionSource(points=pts, lidar_to_ego=_identity_pose(),
|
|
67
|
+
per_point_ts_field="timestamp", per_point_ts_unit="ns")],
|
|
68
|
+
frame_ref_time_us=1_000_000, # 1ms after epoch
|
|
69
|
+
)
|
|
70
|
+
np.testing.assert_allclose(out["t"], [0.0, 1e-6], atol=1e-9)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_missing_intensity_raises():
|
|
74
|
+
pts = np.array([(1.0, 2.0, 3.0)],
|
|
75
|
+
dtype=[("x", "<f4"), ("y", "<f4"), ("z", "<f4")])
|
|
76
|
+
with pytest.raises(ValueError, match="intensity"):
|
|
77
|
+
fuse_pointclouds(
|
|
78
|
+
sources=[FusionSource(points=pts, lidar_to_ego=_identity_pose(), per_point_ts_field=None)],
|
|
79
|
+
frame_ref_time_us=0,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_per_point_ts_offset_microseconds_unit():
|
|
84
|
+
# Points at +1ms vs frame ref, given in microseconds
|
|
85
|
+
pts = np.array(
|
|
86
|
+
[(1.0, 0.0, 0.0, 0.0, 1_000_000),
|
|
87
|
+
(2.0, 0.0, 0.0, 0.0, 1_001_000)],
|
|
88
|
+
dtype=[("x", "<f4"), ("y", "<f4"), ("z", "<f4"), ("intensity", "<f4"), ("timestamp", "<u8")],
|
|
89
|
+
)
|
|
90
|
+
out = fuse_pointclouds(
|
|
91
|
+
sources=[FusionSource(points=pts, lidar_to_ego=_identity_pose(),
|
|
92
|
+
per_point_ts_field="timestamp", per_point_ts_unit="us")],
|
|
93
|
+
frame_ref_time_us=1_000_000,
|
|
94
|
+
)
|
|
95
|
+
np.testing.assert_allclose(out["t"], [0.0, 1e-3], atol=1e-9)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_unsupported_ts_unit_raises():
|
|
99
|
+
pts = np.array(
|
|
100
|
+
[(1.0, 0.0, 0.0, 0.0, 0)],
|
|
101
|
+
dtype=[("x", "<f4"), ("y", "<f4"), ("z", "<f4"), ("intensity", "<f4"), ("timestamp", "<u8")],
|
|
102
|
+
)
|
|
103
|
+
with pytest.raises(ValueError, match="unsupported per_point_ts_unit"):
|
|
104
|
+
fuse_pointclouds(
|
|
105
|
+
sources=[FusionSource(points=pts, lidar_to_ego=_identity_pose(),
|
|
106
|
+
per_point_ts_field="timestamp", per_point_ts_unit="ms")],
|
|
107
|
+
frame_ref_time_us=0,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_unstructured_array_raises():
|
|
112
|
+
pts = np.zeros((3, 4), dtype=np.float32) # plain (N, 4) — not structured
|
|
113
|
+
with pytest.raises(ValueError, match="structured numpy array"):
|
|
114
|
+
fuse_pointclouds(
|
|
115
|
+
sources=[FusionSource(points=pts, lidar_to_ego=_identity_pose(), per_point_ts_field=None)],
|
|
116
|
+
frame_ref_time_us=0,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_dtype_round_trip_via_tofile(tmp_path):
|
|
121
|
+
pts = np.array(
|
|
122
|
+
[(1.5, -2.5, 3.5, 0.25),
|
|
123
|
+
(10.0, 20.0, 30.0, 0.5)],
|
|
124
|
+
dtype=[("x", "<f4"), ("y", "<f4"), ("z", "<f4"), ("intensity", "<f4")],
|
|
125
|
+
)
|
|
126
|
+
out = fuse_pointclouds(
|
|
127
|
+
sources=[FusionSource(points=pts, lidar_to_ego=_identity_pose(), per_point_ts_field=None)],
|
|
128
|
+
frame_ref_time_us=0,
|
|
129
|
+
)
|
|
130
|
+
path = tmp_path / "test.bin"
|
|
131
|
+
out.tofile(str(path))
|
|
132
|
+
assert path.stat().st_size == 2 * 20 # 2 points * 20 bytes/point
|
|
133
|
+
roundtrip = np.fromfile(str(path), dtype=LIDAR_FUSED_DTYPE)
|
|
134
|
+
np.testing.assert_array_equal(roundtrip, out)
|
|
@@ -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
|
)
|
|
@@ -21,10 +21,14 @@ from x4d_devkit.eval import (
|
|
|
21
21
|
nusc_detection_config,
|
|
22
22
|
)
|
|
23
23
|
from x4d_devkit.validation import validate_clip, ValidationReport
|
|
24
|
+
from x4d_devkit.fusion import LIDAR_FUSED_DTYPE, FusionSource, fuse_pointclouds # noqa: E402,F401
|
|
24
25
|
|
|
25
26
|
__all__ = [
|
|
26
27
|
"Annotation",
|
|
27
28
|
"CalibratedSensor",
|
|
29
|
+
"LIDAR_FUSED_DTYPE",
|
|
30
|
+
"FusionSource",
|
|
31
|
+
"fuse_pointclouds",
|
|
28
32
|
"ClipLoader",
|
|
29
33
|
"ClipMeta",
|
|
30
34
|
"DetectionConfig",
|
|
@@ -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
|
|
@@ -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
|
|
|
@@ -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))
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Lidar fusion to ego frame.
|
|
2
|
+
|
|
3
|
+
Single-lidar and multi-lidar share one path: each source provides its points
|
|
4
|
+
in lidar frame plus a 4x4 homogeneous lidar->ego transform. Output is a
|
|
5
|
+
contiguous structured array with the canonical 5-field layout.
|
|
6
|
+
|
|
7
|
+
Used by the X-4D ingestion pipeline to write `samples/lidar/<ts>.bin` and
|
|
8
|
+
`sweeps/lidar/<ts>.bin`. Downstream consumers read with
|
|
9
|
+
`np.fromfile(path, dtype=LIDAR_FUSED_DTYPE)`.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
LIDAR_FUSED_DTYPE = np.dtype([
|
|
20
|
+
("x", "<f4"),
|
|
21
|
+
("y", "<f4"),
|
|
22
|
+
("z", "<f4"),
|
|
23
|
+
("intensity", "<f4"),
|
|
24
|
+
("t", "<f4"),
|
|
25
|
+
])
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class FusionSource:
|
|
30
|
+
points: np.ndarray # structured array; must include x, y, z, intensity
|
|
31
|
+
lidar_to_ego: np.ndarray # (4, 4) float64 homogeneous
|
|
32
|
+
per_point_ts_field: Optional[str] = None # name of u64/i64 ts field, e.g. "timestamp"
|
|
33
|
+
per_point_ts_unit: str = "ns" # "ns" | "us"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _intensity_field(names: tuple[str, ...]) -> str:
|
|
37
|
+
for n in names:
|
|
38
|
+
if "intensity" in n.lower():
|
|
39
|
+
return n
|
|
40
|
+
raise ValueError(
|
|
41
|
+
f"point cloud has no field with 'intensity' in its name (got {names!r}); "
|
|
42
|
+
"fusion refuses to silently zero-fill"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _transform_xyz(xyz: np.ndarray, T: np.ndarray) -> np.ndarray:
|
|
47
|
+
# xyz: (N, 3) float64; T: (4, 4) float64; returns (N, 3) float32
|
|
48
|
+
homog = np.empty((xyz.shape[0], 4), dtype=np.float64)
|
|
49
|
+
homog[:, :3] = xyz
|
|
50
|
+
homog[:, 3] = 1.0
|
|
51
|
+
out = homog @ T.T
|
|
52
|
+
return out[:, :3].astype(np.float32, copy=False)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def fuse_pointclouds(sources: list[FusionSource], frame_ref_time_us: int) -> np.ndarray:
|
|
56
|
+
"""Transform each source into ego frame and concat into the 5-field layout.
|
|
57
|
+
|
|
58
|
+
`frame_ref_time_us` is the synced frame timestamp; per-point `t` is computed
|
|
59
|
+
as `(point_ts - frame_ref) / 1e6` (seconds). When a source has no per-point
|
|
60
|
+
timestamp, `t` is filled with 0 (semantics: no motion-comp info available).
|
|
61
|
+
"""
|
|
62
|
+
if not sources:
|
|
63
|
+
return np.empty((0,), dtype=LIDAR_FUSED_DTYPE)
|
|
64
|
+
|
|
65
|
+
chunks: list[np.ndarray] = []
|
|
66
|
+
for src in sources:
|
|
67
|
+
pts = src.points
|
|
68
|
+
if pts.dtype.names is None:
|
|
69
|
+
raise ValueError(
|
|
70
|
+
"FusionSource.points must be a structured numpy array with named fields"
|
|
71
|
+
)
|
|
72
|
+
names = pts.dtype.names
|
|
73
|
+
for required in ("x", "y", "z"):
|
|
74
|
+
if required not in names:
|
|
75
|
+
raise ValueError(f"source missing field {required!r}; got {names!r}")
|
|
76
|
+
intensity_name = _intensity_field(names)
|
|
77
|
+
|
|
78
|
+
xyz = np.column_stack([pts["x"], pts["y"], pts["z"]]).astype(np.float64, copy=False)
|
|
79
|
+
ego_xyz = _transform_xyz(xyz, np.asarray(src.lidar_to_ego, dtype=np.float64))
|
|
80
|
+
|
|
81
|
+
intensity = np.asarray(pts[intensity_name], dtype=np.float32)
|
|
82
|
+
|
|
83
|
+
if src.per_point_ts_field and src.per_point_ts_field in names:
|
|
84
|
+
raw_ts = np.asarray(pts[src.per_point_ts_field], dtype=np.int64)
|
|
85
|
+
if src.per_point_ts_unit == "ns":
|
|
86
|
+
ref_ns = int(frame_ref_time_us) * 1000
|
|
87
|
+
t = ((raw_ts - ref_ns).astype(np.float64) / 1e9).astype(np.float32)
|
|
88
|
+
elif src.per_point_ts_unit == "us":
|
|
89
|
+
ref_us = int(frame_ref_time_us)
|
|
90
|
+
t = ((raw_ts - ref_us).astype(np.float64) / 1e6).astype(np.float32)
|
|
91
|
+
else:
|
|
92
|
+
raise ValueError(f"unsupported per_point_ts_unit: {src.per_point_ts_unit}")
|
|
93
|
+
else:
|
|
94
|
+
t = np.zeros((ego_xyz.shape[0],), dtype=np.float32)
|
|
95
|
+
|
|
96
|
+
out = np.empty((ego_xyz.shape[0],), dtype=LIDAR_FUSED_DTYPE)
|
|
97
|
+
out["x"] = ego_xyz[:, 0]
|
|
98
|
+
out["y"] = ego_xyz[:, 1]
|
|
99
|
+
out["z"] = ego_xyz[:, 2]
|
|
100
|
+
out["intensity"] = intensity
|
|
101
|
+
out["t"] = t
|
|
102
|
+
chunks.append(out)
|
|
103
|
+
|
|
104
|
+
return np.concatenate(chunks, axis=0)
|
|
@@ -3,6 +3,7 @@ README.md
|
|
|
3
3
|
pyproject.toml
|
|
4
4
|
tests/test_coordinate_frames.py
|
|
5
5
|
tests/test_detection_eval.py
|
|
6
|
+
tests/test_fusion.py
|
|
6
7
|
tests/test_loader.py
|
|
7
8
|
tests/test_matching.py
|
|
8
9
|
tests/test_metrics.py
|
|
@@ -14,6 +15,7 @@ tests/test_transform_chain.py
|
|
|
14
15
|
tests/test_validation.py
|
|
15
16
|
x4d_devkit/__init__.py
|
|
16
17
|
x4d_devkit/cli.py
|
|
18
|
+
x4d_devkit/fusion.py
|
|
17
19
|
x4d_devkit.egg-info/PKG-INFO
|
|
18
20
|
x4d_devkit.egg-info/SOURCES.txt
|
|
19
21
|
x4d_devkit.egg-info/dependency_links.txt
|
|
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
|