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.
Files changed (55) hide show
  1. {x4d_devkit-0.12.0/x4d_devkit.egg-info → x4d_devkit-0.14.2}/PKG-INFO +1 -1
  2. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/pyproject.toml +1 -1
  3. x4d_devkit-0.14.2/tests/test_loader.py +253 -0
  4. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/tests/test_v06_schema.py +2 -2
  5. x4d_devkit-0.14.2/tests/test_v10_schema.py +238 -0
  6. x4d_devkit-0.14.2/tests/test_validation.py +421 -0
  7. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/converters/nuscenes.py +11 -10
  8. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/core/loader.py +61 -8
  9. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/core/models.py +17 -13
  10. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/validation/validator.py +96 -57
  11. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2/x4d_devkit.egg-info}/PKG-INFO +1 -1
  12. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit.egg-info/SOURCES.txt +1 -0
  13. x4d_devkit-0.12.0/tests/test_loader.py +0 -118
  14. x4d_devkit-0.12.0/tests/test_validation.py +0 -202
  15. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/LICENSE +0 -0
  16. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/README.md +0 -0
  17. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/setup.cfg +0 -0
  18. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/tests/test_box.py +0 -0
  19. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/tests/test_coordinate_frames.py +0 -0
  20. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/tests/test_detection_eval.py +0 -0
  21. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/tests/test_evaluate.py +0 -0
  22. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/tests/test_fusion.py +0 -0
  23. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/tests/test_matching.py +0 -0
  24. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/tests/test_metrics.py +0 -0
  25. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/tests/test_models.py +0 -0
  26. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/tests/test_nuscenes_converter.py +0 -0
  27. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/tests/test_token.py +0 -0
  28. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/tests/test_transform.py +0 -0
  29. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/tests/test_transform_chain.py +0 -0
  30. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/tests/test_v06_api_additions.py +0 -0
  31. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/__init__.py +0 -0
  32. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/cli.py +0 -0
  33. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/client/__init__.py +0 -0
  34. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/client/checkpoints.py +0 -0
  35. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/client/client.py +0 -0
  36. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/client/clips.py +0 -0
  37. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/client/projects.py +0 -0
  38. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/client/test_sets.py +0 -0
  39. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/converters/__init__.py +0 -0
  40. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/core/__init__.py +0 -0
  41. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/core/token.py +0 -0
  42. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/core/transform.py +0 -0
  43. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/eval/__init__.py +0 -0
  44. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/eval/api.py +0 -0
  45. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/eval/box.py +0 -0
  46. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/eval/detection.py +0 -0
  47. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/eval/matching.py +0 -0
  48. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/eval/metrics.py +0 -0
  49. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/fusion.py +0 -0
  50. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/validation/__init__.py +0 -0
  51. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit/validation/report.py +0 -0
  52. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit.egg-info/dependency_links.txt +0 -0
  53. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit.egg-info/entry_points.txt +0 -0
  54. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/x4d_devkit.egg-info/requires.txt +0 -0
  55. {x4d_devkit-0.12.0 → x4d_devkit-0.14.2}/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.12.0
3
+ Version: 0.14.2
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "x4d-devkit"
7
- version = "0.12.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 test_clip_meta_annotation_frame_id_default_none():
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.annotation_frame_id is None
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}"