x4d-devkit 0.12.0__tar.gz → 0.14.2__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.12.0/x4d_devkit.egg-info → x4d_devkit-0.14.2}/PKG-INFO +1 -1
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/pyproject.toml +1 -1
- x4d_devkit-0.14.2/tests/test_loader.py +253 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/tests/test_v06_schema.py +2 -2
- x4d_devkit-0.14.2/tests/test_v10_schema.py +238 -0
- x4d_devkit-0.14.2/tests/test_validation.py +421 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/converters/nuscenes.py +11 -10
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/core/loader.py +61 -8
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/core/models.py +17 -13
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/validation/validator.py +96 -57
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2/x4d_devkit.egg-info}/PKG-INFO +1 -1
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit.egg-info/SOURCES.txt +1 -0
- x4d_devkit-0.12.0/tests/test_loader.py +0 -118
- x4d_devkit-0.12.0/tests/test_validation.py +0 -202
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/LICENSE +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/README.md +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/setup.cfg +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/tests/test_box.py +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/tests/test_coordinate_frames.py +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/tests/test_detection_eval.py +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/tests/test_evaluate.py +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/tests/test_fusion.py +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/tests/test_matching.py +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/tests/test_metrics.py +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/tests/test_models.py +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/tests/test_nuscenes_converter.py +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/tests/test_token.py +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/tests/test_transform.py +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/tests/test_transform_chain.py +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/tests/test_v06_api_additions.py +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/__init__.py +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/cli.py +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/client/__init__.py +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/client/checkpoints.py +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/client/client.py +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/client/clips.py +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/client/projects.py +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/client/test_sets.py +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/converters/__init__.py +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/core/__init__.py +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/core/token.py +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/core/transform.py +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/eval/__init__.py +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/eval/api.py +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/eval/box.py +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/eval/detection.py +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/eval/matching.py +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/eval/metrics.py +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/fusion.py +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/validation/__init__.py +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/validation/report.py +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit.egg-info/dependency_links.txt +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit.egg-info/entry_points.txt +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit.egg-info/requires.txt +0 -0
- {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/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.14.2"
|
|
8
8
|
description = "X-4D dataset format SDK — load, validate, evaluate, and convert autonomous driving datasets"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Integration tests for ClipLoader — requires nuScenes mini data."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from x4d_devkit.core.loader import ClipLoader
|
|
9
|
+
from x4d_devkit.core.models import (
|
|
10
|
+
ClipMeta, CalibratedSensor, EgoPose, Sample,
|
|
11
|
+
SampleData, Annotation, Instance,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
NUSCENES_DATAROOT = "/data/PublicDatasets/nuscenes"
|
|
15
|
+
NUSCENES_AVAILABLE = os.path.isdir(os.path.join(NUSCENES_DATAROOT, "v1.0-mini"))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture(scope="module")
|
|
19
|
+
def clip_dir(tmp_path_factory):
|
|
20
|
+
"""Convert scene-0061 once and return the clip directory path."""
|
|
21
|
+
if not NUSCENES_AVAILABLE:
|
|
22
|
+
pytest.skip("nuScenes data not found")
|
|
23
|
+
from x4d_devkit.converters.nuscenes import NuScenesConverter
|
|
24
|
+
|
|
25
|
+
out = tmp_path_factory.mktemp("loader_test")
|
|
26
|
+
conv = NuScenesConverter(
|
|
27
|
+
dataroot=NUSCENES_DATAROOT, version="v1.0-mini", output_dir=str(out),
|
|
28
|
+
)
|
|
29
|
+
scene = conv.nusc.scene[0]
|
|
30
|
+
clip_id = conv.convert_scene(scene["token"])
|
|
31
|
+
return out / "clips" / clip_id
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.mark.skipif(not NUSCENES_AVAILABLE, reason="nuScenes data not found")
|
|
35
|
+
class TestClipLoader:
|
|
36
|
+
def test_load_meta(self, clip_dir):
|
|
37
|
+
loader = ClipLoader(clip_dir)
|
|
38
|
+
assert isinstance(loader.meta, ClipMeta)
|
|
39
|
+
assert loader.meta.clip_id.startswith("2018")
|
|
40
|
+
assert loader.meta.num_keyframes > 0
|
|
41
|
+
|
|
42
|
+
def test_samples(self, clip_dir):
|
|
43
|
+
loader = ClipLoader(clip_dir)
|
|
44
|
+
assert len(loader.samples) > 0
|
|
45
|
+
assert all(isinstance(s, Sample) for s in loader.samples)
|
|
46
|
+
timestamps = [s.timestamp_us for s in loader.samples]
|
|
47
|
+
assert timestamps == sorted(timestamps)
|
|
48
|
+
|
|
49
|
+
def test_ego_poses(self, clip_dir):
|
|
50
|
+
loader = ClipLoader(clip_dir)
|
|
51
|
+
assert len(loader.ego_poses) > 0
|
|
52
|
+
assert all(isinstance(ep, EgoPose) for ep in loader.ego_poses)
|
|
53
|
+
|
|
54
|
+
def test_calibrated_sensors(self, clip_dir):
|
|
55
|
+
loader = ClipLoader(clip_dir)
|
|
56
|
+
assert len(loader.calibrated_sensors) == 7
|
|
57
|
+
channels = {cs.channel for cs in loader.calibrated_sensors}
|
|
58
|
+
assert "CAM_FRONT" in channels
|
|
59
|
+
assert "LIDAR_TOP" in channels
|
|
60
|
+
|
|
61
|
+
def test_annotations_and_instances(self, clip_dir):
|
|
62
|
+
loader = ClipLoader(clip_dir)
|
|
63
|
+
assert len(loader.annotations) > 0
|
|
64
|
+
assert len(loader.instances) > 0
|
|
65
|
+
assert all(isinstance(a, Annotation) for a in loader.annotations)
|
|
66
|
+
assert all(isinstance(i, Instance) for i in loader.instances)
|
|
67
|
+
|
|
68
|
+
def test_get_by_token(self, clip_dir):
|
|
69
|
+
loader = ClipLoader(clip_dir)
|
|
70
|
+
sample = loader.samples[0]
|
|
71
|
+
found = loader.get(sample.token)
|
|
72
|
+
assert found is sample
|
|
73
|
+
|
|
74
|
+
def test_get_unknown_token_raises(self, clip_dir):
|
|
75
|
+
loader = ClipLoader(clip_dir)
|
|
76
|
+
with pytest.raises(KeyError):
|
|
77
|
+
loader.get("nonexistent_token")
|
|
78
|
+
|
|
79
|
+
def test_sample_data_for_channel(self, clip_dir):
|
|
80
|
+
loader = ClipLoader(clip_dir)
|
|
81
|
+
cam_data = loader.sample_data_for_channel("CAM_FRONT")
|
|
82
|
+
assert len(cam_data) > 0
|
|
83
|
+
assert all(sd.channel == "CAM_FRONT" for sd in cam_data)
|
|
84
|
+
timestamps = [sd.timestamp_us for sd in cam_data]
|
|
85
|
+
assert timestamps == sorted(timestamps)
|
|
86
|
+
|
|
87
|
+
def test_sample_data_for_sample(self, clip_dir):
|
|
88
|
+
loader = ClipLoader(clip_dir)
|
|
89
|
+
sample = loader.samples[0]
|
|
90
|
+
sds = loader.sample_data_for_sample(sample.token)
|
|
91
|
+
assert len(sds) > 0
|
|
92
|
+
assert all(sd.sample_token == sample.token for sd in sds)
|
|
93
|
+
|
|
94
|
+
def test_annotations_for_sample(self, clip_dir):
|
|
95
|
+
loader = ClipLoader(clip_dir)
|
|
96
|
+
for sample in loader.samples:
|
|
97
|
+
anns = loader.annotations_for_sample(sample.token)
|
|
98
|
+
if anns:
|
|
99
|
+
assert all(a.sample_token == sample.token for a in anns)
|
|
100
|
+
break
|
|
101
|
+
|
|
102
|
+
def test_load_point_cloud(self, clip_dir):
|
|
103
|
+
loader = ClipLoader(clip_dir)
|
|
104
|
+
lidar_data = loader.sample_data_for_channel("LIDAR_TOP")
|
|
105
|
+
kf = next(sd for sd in lidar_data if sd.is_keyframe)
|
|
106
|
+
pc = loader.load_point_cloud(kf)
|
|
107
|
+
assert isinstance(pc, np.ndarray)
|
|
108
|
+
assert pc.ndim == 2
|
|
109
|
+
assert pc.shape[1] == 5 # x, y, z, intensity, ring_index
|
|
110
|
+
assert pc.shape[0] > 100
|
|
111
|
+
|
|
112
|
+
def test_load_image_path(self, clip_dir):
|
|
113
|
+
loader = ClipLoader(clip_dir)
|
|
114
|
+
cam_data = loader.sample_data_for_channel("CAM_FRONT")
|
|
115
|
+
kf = next(sd for sd in cam_data if sd.is_keyframe)
|
|
116
|
+
path = loader.load_image_path(kf)
|
|
117
|
+
assert path.is_file()
|
|
118
|
+
assert path.suffix == ".jpg"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
# Self-contained v0.9 fixture (does not require nuScenes data) — used to test
|
|
123
|
+
# that ego_pose.json is optional in v0.9.
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
import json
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _build_v09_clip(clip_dir, *, with_ego_pose: bool):
|
|
130
|
+
clip_dir.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
(clip_dir / "samples" / "lidar_top").mkdir(parents=True)
|
|
132
|
+
ts = 1000000
|
|
133
|
+
|
|
134
|
+
(clip_dir / "meta.json").write_text(json.dumps({
|
|
135
|
+
"clip_id": clip_dir.name,
|
|
136
|
+
"schema_version": "0.9",
|
|
137
|
+
"session_id": "s",
|
|
138
|
+
"vehicle_id": "v01",
|
|
139
|
+
"capture_time_utc": "2024-01-01T00:00:00Z",
|
|
140
|
+
"duration_s": 1.0,
|
|
141
|
+
"keyframe_count": 1,
|
|
142
|
+
"sweep_count": 0,
|
|
143
|
+
"annotation_frame_id": "lidar_top",
|
|
144
|
+
"ego_pose_frame_id": "base_link",
|
|
145
|
+
"sensors": {
|
|
146
|
+
"lidar_top": {
|
|
147
|
+
"modality": "lidar",
|
|
148
|
+
"frame_id": "lidar_top",
|
|
149
|
+
"point_format": {
|
|
150
|
+
"fields": ["x", "y", "z", "intensity", "ring_index"],
|
|
151
|
+
"types": ["float32"] * 5,
|
|
152
|
+
"bytes_per_point": 20,
|
|
153
|
+
},
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
}))
|
|
157
|
+
|
|
158
|
+
(clip_dir / "calibrated_sensor.json").write_text(json.dumps([
|
|
159
|
+
{
|
|
160
|
+
"calibrated_sensor_token": "cs_root",
|
|
161
|
+
"channel": "base_link",
|
|
162
|
+
"parent_frame": "base_link",
|
|
163
|
+
"translation": {"x": 0, "y": 0, "z": 0},
|
|
164
|
+
"rotation": {"qx": 0, "qy": 0, "qz": 0, "qw": 1},
|
|
165
|
+
"intrinsics": None,
|
|
166
|
+
"distortion": None,
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
"calibrated_sensor_token": "cs_lidar",
|
|
170
|
+
"channel": "lidar_top",
|
|
171
|
+
"parent_frame": "base_link",
|
|
172
|
+
"translation": {"x": 0, "y": 0, "z": 1.8},
|
|
173
|
+
"rotation": {"qx": 0, "qy": 0, "qz": 0, "qw": 1},
|
|
174
|
+
"intrinsics": None,
|
|
175
|
+
"distortion": None,
|
|
176
|
+
},
|
|
177
|
+
]))
|
|
178
|
+
|
|
179
|
+
if with_ego_pose:
|
|
180
|
+
(clip_dir / "ego_pose.json").write_text(json.dumps([
|
|
181
|
+
{
|
|
182
|
+
"ego_pose_token": "ep_0",
|
|
183
|
+
"timestamp_us": ts,
|
|
184
|
+
"translation": {"x": 0, "y": 0, "z": 0},
|
|
185
|
+
"rotation": {"qx": 0, "qy": 0, "qz": 0, "qw": 1},
|
|
186
|
+
}
|
|
187
|
+
]))
|
|
188
|
+
|
|
189
|
+
(clip_dir / "sample.json").write_text(json.dumps([
|
|
190
|
+
{
|
|
191
|
+
"sample_token": "s_0",
|
|
192
|
+
"clip_id": clip_dir.name,
|
|
193
|
+
"timestamp_us": ts,
|
|
194
|
+
"is_keyframe": True,
|
|
195
|
+
"prev": None,
|
|
196
|
+
"next": None,
|
|
197
|
+
}
|
|
198
|
+
]))
|
|
199
|
+
|
|
200
|
+
(clip_dir / "sample_data.json").write_text(json.dumps([
|
|
201
|
+
{
|
|
202
|
+
"sample_data_token": "sd_0",
|
|
203
|
+
"sample_token": "s_0",
|
|
204
|
+
"channel": "lidar_top",
|
|
205
|
+
"timestamp_us": ts,
|
|
206
|
+
"file_path": "samples/lidar_top/1000000.bin",
|
|
207
|
+
"ego_pose_token": "ep_0",
|
|
208
|
+
"calibrated_sensor_token": "cs_lidar",
|
|
209
|
+
"is_keyframe": True,
|
|
210
|
+
"width": None,
|
|
211
|
+
"height": None,
|
|
212
|
+
"prev": None,
|
|
213
|
+
"next": None,
|
|
214
|
+
}
|
|
215
|
+
]))
|
|
216
|
+
|
|
217
|
+
(clip_dir / "annotation.json").write_text("[]")
|
|
218
|
+
(clip_dir / "instance.json").write_text("[]")
|
|
219
|
+
|
|
220
|
+
pts = np.zeros((10, 5), dtype=np.float32)
|
|
221
|
+
pts.tofile(str(clip_dir / "samples" / "lidar_top" / "1000000.bin"))
|
|
222
|
+
return clip_dir
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class TestEgoPoseOptional:
|
|
226
|
+
"""v0.9: ego_pose.json is optional (absent = SLAM not run). Self-contained
|
|
227
|
+
tests that do not require the nuScenes dataset."""
|
|
228
|
+
|
|
229
|
+
def test_has_ego_pose_true_when_file_exists(self, tmp_path):
|
|
230
|
+
clip = _build_v09_clip(tmp_path / "clip_with_pose", with_ego_pose=True)
|
|
231
|
+
loader = ClipLoader(clip)
|
|
232
|
+
assert loader.has_ego_pose is True
|
|
233
|
+
assert len(loader.ego_poses) == 1
|
|
234
|
+
|
|
235
|
+
def test_has_ego_pose_false_when_file_absent(self, tmp_path):
|
|
236
|
+
clip = _build_v09_clip(tmp_path / "clip_no_pose", with_ego_pose=False)
|
|
237
|
+
loader = ClipLoader(clip)
|
|
238
|
+
assert loader.has_ego_pose is False
|
|
239
|
+
assert loader.ego_poses == []
|
|
240
|
+
|
|
241
|
+
def test_clip_world_transform_raises_without_pose(self, tmp_path):
|
|
242
|
+
from x4d_devkit.core.loader import CLIP_WORLD_FRAME_ID
|
|
243
|
+
clip = _build_v09_clip(tmp_path / "clip_no_pose2", with_ego_pose=False)
|
|
244
|
+
loader = ClipLoader(clip)
|
|
245
|
+
sd = next(s for s in loader.sample_data_for_channel("lidar_top") if s.is_keyframe)
|
|
246
|
+
with pytest.raises(ValueError, match="has_ego_pose"):
|
|
247
|
+
loader.get_transform(loader.sensor_frame_id(sd), CLIP_WORLD_FRAME_ID, sd=sd)
|
|
248
|
+
|
|
249
|
+
def test_sensor_to_sensor_transform_works_without_pose(self, tmp_path):
|
|
250
|
+
clip = _build_v09_clip(tmp_path / "clip_no_pose3", with_ego_pose=False)
|
|
251
|
+
loader = ClipLoader(clip)
|
|
252
|
+
T = loader.get_transform("lidar_top", "base_link")
|
|
253
|
+
assert T.as_matrix.shape == (4, 4)
|
|
@@ -95,13 +95,13 @@ def test_sensor_info_no_frame_id_omits_key_in_to_dict():
|
|
|
95
95
|
assert "frame_id" not in info.to_dict()
|
|
96
96
|
|
|
97
97
|
|
|
98
|
-
def
|
|
98
|
+
def test_clip_meta_annotation_source_channel_default_none():
|
|
99
99
|
meta = ClipMeta(
|
|
100
100
|
clip_id="x", schema_version="0.5", session_id="s", vehicle_id="v",
|
|
101
101
|
capture_time_utc="t", duration_s=0.0, num_keyframes=0, num_sweeps=0,
|
|
102
102
|
sensors={},
|
|
103
103
|
)
|
|
104
|
-
assert meta.
|
|
104
|
+
assert meta.annotation_source_channel is None
|
|
105
105
|
|
|
106
106
|
|
|
107
107
|
# --- Loader: annotation_frame_id property ---
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Tests for v0.10 schema: annotation_source_channel replaces annotation_frame_id.
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- ClipMeta.annotation_source_channel round-trip
|
|
5
|
+
- ClipLoader.annotation_source_channel + derived annotation_frame_id
|
|
6
|
+
- Reverse-derivation back-compat for v0.6 – v0.9 clips
|
|
7
|
+
- Validator: required field, must index meta.sensors, must point at lidar
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import pytest
|
|
18
|
+
|
|
19
|
+
from x4d_devkit import ClipLoader, validate_clip
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _q_from_yaw(yaw: float) -> dict[str, float]:
|
|
23
|
+
return {"qx": 0.0, "qy": 0.0, "qz": math.sin(yaw / 2), "qw": math.cos(yaw / 2)}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _identity_calib(channel: str, modality: str = "lidar") -> dict:
|
|
27
|
+
return {
|
|
28
|
+
"calibrated_sensor_token": f"cs-{channel}",
|
|
29
|
+
"channel": channel,
|
|
30
|
+
"parent_frame": channel,
|
|
31
|
+
"translation": {"x": 0, "y": 0, "z": 0},
|
|
32
|
+
"rotation": {"qx": 0, "qy": 0, "qz": 0, "qw": 1},
|
|
33
|
+
"intrinsics": None,
|
|
34
|
+
"distortion": None,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _write_v10_clip(
|
|
39
|
+
clip_dir: Path,
|
|
40
|
+
*,
|
|
41
|
+
annotation_source_channel: str | None,
|
|
42
|
+
sensors_meta: dict,
|
|
43
|
+
calibrated: list[dict],
|
|
44
|
+
samples: list[dict] | None = None,
|
|
45
|
+
sample_data: list[dict] | None = None,
|
|
46
|
+
annotations: list[dict] | None = None,
|
|
47
|
+
extra_meta: dict | None = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
clip_dir.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
meta: dict = {
|
|
51
|
+
"clip_id": "test-v10",
|
|
52
|
+
"schema_version": "0.10",
|
|
53
|
+
"session_id": "s",
|
|
54
|
+
"vehicle_id": "v",
|
|
55
|
+
"capture_time_utc": "2026-05-11T00:00:00Z",
|
|
56
|
+
"duration_s": 0.0,
|
|
57
|
+
"keyframe_count": len(samples or []) or 1,
|
|
58
|
+
"sweep_count": 0,
|
|
59
|
+
"sensors": sensors_meta,
|
|
60
|
+
}
|
|
61
|
+
if annotation_source_channel is not None:
|
|
62
|
+
meta["annotation_source_channel"] = annotation_source_channel
|
|
63
|
+
if extra_meta:
|
|
64
|
+
meta.update(extra_meta)
|
|
65
|
+
(clip_dir / "meta.json").write_text(json.dumps(meta))
|
|
66
|
+
(clip_dir / "calibrated_sensor.json").write_text(json.dumps(calibrated))
|
|
67
|
+
(clip_dir / "sample.json").write_text(json.dumps(samples or [{
|
|
68
|
+
"sample_token": "s-0", "timestamp_us": 0, "is_keyframe": True,
|
|
69
|
+
"prev": "", "next": "",
|
|
70
|
+
}]))
|
|
71
|
+
(clip_dir / "sample_data.json").write_text(json.dumps(sample_data or [{
|
|
72
|
+
"sample_data_token": "sd-0",
|
|
73
|
+
"sample_token": "s-0",
|
|
74
|
+
"channel": calibrated[0]["channel"],
|
|
75
|
+
"timestamp_us": 0,
|
|
76
|
+
"file_path": "samples/x/0.bin",
|
|
77
|
+
"ego_pose_token": "ep-0",
|
|
78
|
+
"calibrated_sensor_token": calibrated[0]["calibrated_sensor_token"],
|
|
79
|
+
"is_keyframe": True,
|
|
80
|
+
"width": None, "height": None,
|
|
81
|
+
"prev": "", "next": "",
|
|
82
|
+
}]))
|
|
83
|
+
(clip_dir / "annotation.json").write_text(json.dumps(annotations or []))
|
|
84
|
+
(clip_dir / "instance.json").write_text(json.dumps([]))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# --- Model + loader: v0.10 round-trip ---
|
|
88
|
+
|
|
89
|
+
def test_loader_annotation_source_channel_explicit(tmp_path):
|
|
90
|
+
_write_v10_clip(
|
|
91
|
+
tmp_path,
|
|
92
|
+
annotation_source_channel="lidar",
|
|
93
|
+
sensors_meta={"lidar": {"modality": "lidar", "frame_id": "base_link",
|
|
94
|
+
"point_format": {"fields": ["x", "y", "z"],
|
|
95
|
+
"types": ["float32"] * 3,
|
|
96
|
+
"bytes_per_point": 12}}},
|
|
97
|
+
calibrated=[_identity_calib("base_link")],
|
|
98
|
+
)
|
|
99
|
+
loader = ClipLoader(tmp_path)
|
|
100
|
+
assert loader.annotation_source_channel == "lidar"
|
|
101
|
+
assert loader.annotation_frame_id == "base_link" # derived
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_loader_derived_frame_id_falls_back_to_channel_when_no_frame_id(tmp_path):
|
|
105
|
+
"""Sensor without explicit frame_id falls back to channel name (v0.4 convention)."""
|
|
106
|
+
_write_v10_clip(
|
|
107
|
+
tmp_path,
|
|
108
|
+
annotation_source_channel="lidar_top",
|
|
109
|
+
sensors_meta={"lidar_top": {"modality": "lidar"}}, # no frame_id
|
|
110
|
+
calibrated=[_identity_calib("lidar_top")],
|
|
111
|
+
)
|
|
112
|
+
loader = ClipLoader(tmp_path)
|
|
113
|
+
assert loader.annotation_source_channel == "lidar_top"
|
|
114
|
+
assert loader.annotation_frame_id == "lidar_top" # channel ≡ frame_id fallback
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# --- Back-compat: v0.6 – v0.9 clips reverse-derive annotation_source_channel ---
|
|
118
|
+
|
|
119
|
+
def test_loader_back_compat_v09_derives_source_channel_from_frame_id(tmp_path):
|
|
120
|
+
"""v0.9 clip with annotation_frame_id but no annotation_source_channel:
|
|
121
|
+
loader derives the channel by reverse-lookup on (modality=lidar, frame_id match).
|
|
122
|
+
"""
|
|
123
|
+
(tmp_path / "meta.json").write_text(json.dumps({
|
|
124
|
+
"clip_id": "back-compat",
|
|
125
|
+
"schema_version": "0.9",
|
|
126
|
+
"session_id": "s", "vehicle_id": "v",
|
|
127
|
+
"capture_time_utc": "2026-05-01T00:00:00Z",
|
|
128
|
+
"duration_s": 0.0, "keyframe_count": 0, "sweep_count": 0,
|
|
129
|
+
"annotation_frame_id": "base_link",
|
|
130
|
+
"sensors": {"lidar": {"modality": "lidar", "frame_id": "base_link"}},
|
|
131
|
+
}))
|
|
132
|
+
(tmp_path / "calibrated_sensor.json").write_text(json.dumps([_identity_calib("base_link")]))
|
|
133
|
+
(tmp_path / "sample.json").write_text(json.dumps([]))
|
|
134
|
+
(tmp_path / "sample_data.json").write_text(json.dumps([]))
|
|
135
|
+
(tmp_path / "annotation.json").write_text(json.dumps([]))
|
|
136
|
+
(tmp_path / "instance.json").write_text(json.dumps([]))
|
|
137
|
+
|
|
138
|
+
loader = ClipLoader(tmp_path)
|
|
139
|
+
assert loader.annotation_source_channel == "lidar"
|
|
140
|
+
assert loader.annotation_frame_id == "base_link"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_loader_back_compat_ambiguous_multi_lidar_leaves_source_channel_none(tmp_path):
|
|
144
|
+
"""Two lidars share frame_id=base_link — reverse-lookup ambiguous, no derivation."""
|
|
145
|
+
(tmp_path / "meta.json").write_text(json.dumps({
|
|
146
|
+
"clip_id": "ambiguous",
|
|
147
|
+
"schema_version": "0.9",
|
|
148
|
+
"session_id": "s", "vehicle_id": "v",
|
|
149
|
+
"capture_time_utc": "2026-05-01T00:00:00Z",
|
|
150
|
+
"duration_s": 0.0, "keyframe_count": 0, "sweep_count": 0,
|
|
151
|
+
"annotation_frame_id": "base_link",
|
|
152
|
+
"sensors": {
|
|
153
|
+
"lidar": {"modality": "lidar", "frame_id": "base_link"},
|
|
154
|
+
"lidar_raw": {"modality": "lidar", "frame_id": "base_link"},
|
|
155
|
+
},
|
|
156
|
+
}))
|
|
157
|
+
(tmp_path / "calibrated_sensor.json").write_text(json.dumps([_identity_calib("base_link")]))
|
|
158
|
+
(tmp_path / "sample.json").write_text(json.dumps([]))
|
|
159
|
+
(tmp_path / "sample_data.json").write_text(json.dumps([]))
|
|
160
|
+
(tmp_path / "annotation.json").write_text(json.dumps([]))
|
|
161
|
+
(tmp_path / "instance.json").write_text(json.dumps([]))
|
|
162
|
+
|
|
163
|
+
loader = ClipLoader(tmp_path)
|
|
164
|
+
assert loader.annotation_source_channel is None
|
|
165
|
+
assert loader.annotation_frame_id == "base_link" # falls back to pre-v0.6 default
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# --- Validator: v0.10 invariants ---
|
|
169
|
+
|
|
170
|
+
def test_validate_v10_missing_annotation_source_channel_errors(tmp_path):
|
|
171
|
+
_write_v10_clip(
|
|
172
|
+
tmp_path,
|
|
173
|
+
annotation_source_channel=None, # missing on purpose
|
|
174
|
+
sensors_meta={"lidar": {"modality": "lidar", "frame_id": "base_link"}},
|
|
175
|
+
calibrated=[_identity_calib("base_link")],
|
|
176
|
+
)
|
|
177
|
+
report = validate_clip(tmp_path)
|
|
178
|
+
assert any(i.check == "annotation_source_channel" for i in report.errors)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_validate_v10_unknown_source_channel_errors(tmp_path):
|
|
182
|
+
_write_v10_clip(
|
|
183
|
+
tmp_path,
|
|
184
|
+
annotation_source_channel="ghost",
|
|
185
|
+
sensors_meta={"lidar": {"modality": "lidar", "frame_id": "base_link"}},
|
|
186
|
+
calibrated=[_identity_calib("base_link")],
|
|
187
|
+
)
|
|
188
|
+
report = validate_clip(tmp_path)
|
|
189
|
+
msgs = [i.message for i in report.errors if i.check == "annotation_source_channel"]
|
|
190
|
+
assert any("not in meta.sensors" in m for m in msgs)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_validate_v10_camera_source_channel_errors(tmp_path):
|
|
194
|
+
_write_v10_clip(
|
|
195
|
+
tmp_path,
|
|
196
|
+
annotation_source_channel="cam_front",
|
|
197
|
+
sensors_meta={"cam_front": {"modality": "camera", "frame_id": "cam_front"}},
|
|
198
|
+
calibrated=[_identity_calib("cam_front", modality="camera")],
|
|
199
|
+
)
|
|
200
|
+
report = validate_clip(tmp_path)
|
|
201
|
+
msgs = [i.message for i in report.errors if i.check == "annotation_source_channel"]
|
|
202
|
+
assert any("modality" in m for m in msgs)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_validate_v10_legacy_annotation_frame_id_removed(tmp_path):
|
|
206
|
+
"""Carrying meta.annotation_frame_id in a v0.10 clip is now an error
|
|
207
|
+
(the field was removed; use annotation_source_channel)."""
|
|
208
|
+
_write_v10_clip(
|
|
209
|
+
tmp_path,
|
|
210
|
+
annotation_source_channel="lidar",
|
|
211
|
+
sensors_meta={"lidar": {"modality": "lidar", "frame_id": "base_link"}},
|
|
212
|
+
calibrated=[_identity_calib("base_link")],
|
|
213
|
+
extra_meta={"annotation_frame_id": "base_link"},
|
|
214
|
+
)
|
|
215
|
+
report = validate_clip(tmp_path)
|
|
216
|
+
assert any(i.check == "annotation_frame_id_removed" for i in report.errors)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def test_validate_v10_passes_minimal_valid(tmp_path):
|
|
220
|
+
_write_v10_clip(
|
|
221
|
+
tmp_path,
|
|
222
|
+
annotation_source_channel="lidar",
|
|
223
|
+
sensors_meta={"lidar": {"modality": "lidar", "frame_id": "base_link",
|
|
224
|
+
"point_format": {"fields": ["x", "y", "z", "intensity", "t"],
|
|
225
|
+
"types": ["float32"] * 5,
|
|
226
|
+
"bytes_per_point": 20}}},
|
|
227
|
+
calibrated=[_identity_calib("base_link")],
|
|
228
|
+
)
|
|
229
|
+
pcd_path = tmp_path / "samples/x/0.bin"
|
|
230
|
+
pcd_path.parent.mkdir(parents=True, exist_ok=True)
|
|
231
|
+
np.zeros(5, dtype=np.float32).tofile(pcd_path)
|
|
232
|
+
|
|
233
|
+
report = validate_clip(tmp_path)
|
|
234
|
+
schema_errors = [
|
|
235
|
+
i for i in report.errors
|
|
236
|
+
if i.check in {"annotation_source_channel", "annotation_frame_id_removed"}
|
|
237
|
+
]
|
|
238
|
+
assert schema_errors == [], f"unexpected schema errors: {schema_errors}"
|