x4d-devkit 0.8.0__tar.gz → 0.9.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.8.0/x4d_devkit.egg-info → x4d_devkit-0.9.0}/PKG-INFO +1 -1
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/pyproject.toml +1 -1
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/tests/test_coordinate_frames.py +1 -1
- x4d_devkit-0.9.0/tests/test_v06_schema.py +414 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/core/loader.py +101 -19
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/core/models.py +28 -11
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/core/transform.py +35 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/validation/validator.py +51 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0/x4d_devkit.egg-info}/PKG-INFO +1 -1
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit.egg-info/SOURCES.txt +1 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/LICENSE +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/README.md +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/setup.cfg +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/tests/test_box.py +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/tests/test_detection_eval.py +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/tests/test_evaluate.py +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/tests/test_fusion.py +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/tests/test_loader.py +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/tests/test_matching.py +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/tests/test_metrics.py +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/tests/test_models.py +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/tests/test_nuscenes_converter.py +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/tests/test_token.py +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/tests/test_transform.py +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/tests/test_transform_chain.py +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/tests/test_v06_api_additions.py +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/tests/test_validation.py +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/__init__.py +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/cli.py +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/client/__init__.py +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/client/checkpoints.py +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/client/client.py +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/client/clips.py +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/client/projects.py +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/client/test_sets.py +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/converters/__init__.py +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/converters/nuscenes.py +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/core/__init__.py +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/core/token.py +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/eval/__init__.py +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/eval/api.py +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/eval/box.py +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/eval/detection.py +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/eval/matching.py +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/eval/metrics.py +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/fusion.py +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/validation/__init__.py +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit/validation/report.py +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit.egg-info/dependency_links.txt +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit.egg-info/entry_points.txt +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.0}/x4d_devkit.egg-info/requires.txt +0 -0
- {x4d_devkit-0.8.0 → x4d_devkit-0.9.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.9.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"
|
|
@@ -331,7 +331,7 @@ class TestAnnotationsForSampleFrames:
|
|
|
331
331
|
assert not np.allclose(anns_world[0].translation, anns_ego[0].translation, atol=0.1)
|
|
332
332
|
|
|
333
333
|
def test_sensor_frame_raises(self, loader):
|
|
334
|
-
with pytest.raises(ValueError, match="
|
|
334
|
+
with pytest.raises(ValueError, match="ambiguous"):
|
|
335
335
|
loader.annotations_for_sample("s-0", frame="sensor")
|
|
336
336
|
|
|
337
337
|
def test_invalid_frame_raises(self, loader):
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
"""Tests for v0.6 schema additions: annotation_frame_id + per-sensor frame_id.
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- SensorInfo.frame_id round-trip
|
|
5
|
+
- ClipMeta.annotation_frame_id round-trip
|
|
6
|
+
- ClipLoader.annotation_frame_id property (explicit + back-compat default)
|
|
7
|
+
- ClipLoader.frame_id_for_channel
|
|
8
|
+
- annotations_for_sample(frame=<arbitrary frame_id>)
|
|
9
|
+
- TransformChain.get_ego_from_frame_id
|
|
10
|
+
- validate_clip schema 0.6 strict checks
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import math
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
import pytest
|
|
21
|
+
|
|
22
|
+
from x4d_devkit import ClipLoader, validate_clip
|
|
23
|
+
from x4d_devkit.core.models import ClipMeta, PointFormat, SensorInfo
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _q_from_yaw(yaw: float) -> dict[str, float]:
|
|
27
|
+
return {"qx": 0.0, "qy": 0.0, "qz": math.sin(yaw / 2), "qw": math.cos(yaw / 2)}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _write_clip(
|
|
31
|
+
clip_dir: Path,
|
|
32
|
+
*,
|
|
33
|
+
schema_version: str,
|
|
34
|
+
annotation_frame_id: str | None,
|
|
35
|
+
sensors_meta: dict,
|
|
36
|
+
calibrated: list[dict],
|
|
37
|
+
samples: list[dict] | None = None,
|
|
38
|
+
sample_data: list[dict] | None = None,
|
|
39
|
+
annotations: list[dict] | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
clip_dir.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
meta = {
|
|
43
|
+
"clip_id": "test-v06",
|
|
44
|
+
"schema_version": schema_version,
|
|
45
|
+
"session_id": "s",
|
|
46
|
+
"vehicle_id": "v",
|
|
47
|
+
"capture_time_utc": "2026-05-06T00:00:00Z",
|
|
48
|
+
"duration_s": 0.0,
|
|
49
|
+
"keyframe_count": len(samples or []),
|
|
50
|
+
"sweep_count": 0,
|
|
51
|
+
"sensors": sensors_meta,
|
|
52
|
+
}
|
|
53
|
+
if annotation_frame_id is not None:
|
|
54
|
+
meta["annotation_frame_id"] = annotation_frame_id
|
|
55
|
+
(clip_dir / "meta.json").write_text(json.dumps(meta))
|
|
56
|
+
(clip_dir / "calibrated_sensor.json").write_text(json.dumps(calibrated))
|
|
57
|
+
(clip_dir / "ego_pose.json").write_text(json.dumps([{
|
|
58
|
+
"ego_pose_token": "ep-0",
|
|
59
|
+
"timestamp_us": 0,
|
|
60
|
+
"translation": {"x": 0, "y": 0, "z": 0},
|
|
61
|
+
"rotation": {"qx": 0, "qy": 0, "qz": 0, "qw": 1},
|
|
62
|
+
}]))
|
|
63
|
+
(clip_dir / "sample.json").write_text(json.dumps(samples or [{
|
|
64
|
+
"sample_token": "s-0", "timestamp_us": 0, "is_keyframe": True,
|
|
65
|
+
"prev": "", "next": "",
|
|
66
|
+
}]))
|
|
67
|
+
(clip_dir / "sample_data.json").write_text(json.dumps(sample_data or [{
|
|
68
|
+
"sample_data_token": "sd-0",
|
|
69
|
+
"sample_token": "s-0",
|
|
70
|
+
"channel": calibrated[0]["channel"],
|
|
71
|
+
"timestamp_us": 0,
|
|
72
|
+
"file_path": "samples/x/0.bin",
|
|
73
|
+
"ego_pose_token": "ep-0",
|
|
74
|
+
"calibrated_sensor_token": calibrated[0]["calibrated_sensor_token"],
|
|
75
|
+
"is_keyframe": True,
|
|
76
|
+
"width": None, "height": None,
|
|
77
|
+
"prev": "", "next": "",
|
|
78
|
+
}]))
|
|
79
|
+
(clip_dir / "annotation.json").write_text(json.dumps(annotations or []))
|
|
80
|
+
(clip_dir / "instance.json").write_text(json.dumps([]))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# --- Model round-trip ---
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_sensor_info_frame_id_round_trip():
|
|
87
|
+
info = SensorInfo.from_dict({"modality": "lidar", "frame_id": "LIDAR_TOP"})
|
|
88
|
+
assert info.frame_id == "LIDAR_TOP"
|
|
89
|
+
assert info.to_dict()["frame_id"] == "LIDAR_TOP"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_sensor_info_no_frame_id_omits_key_in_to_dict():
|
|
93
|
+
info = SensorInfo.from_dict({"modality": "camera"})
|
|
94
|
+
assert info.frame_id is None
|
|
95
|
+
assert "frame_id" not in info.to_dict()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_clip_meta_annotation_frame_id_default_none():
|
|
99
|
+
meta = ClipMeta(
|
|
100
|
+
clip_id="x", schema_version="0.5", session_id="s", vehicle_id="v",
|
|
101
|
+
capture_time_utc="t", duration_s=0.0, num_keyframes=0, num_sweeps=0,
|
|
102
|
+
sensors={},
|
|
103
|
+
)
|
|
104
|
+
assert meta.annotation_frame_id is None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# --- Loader: annotation_frame_id property ---
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_loader_annotation_frame_id_explicit(tmp_path):
|
|
111
|
+
_write_clip(
|
|
112
|
+
tmp_path,
|
|
113
|
+
schema_version="0.6",
|
|
114
|
+
annotation_frame_id="LIDAR_TOP",
|
|
115
|
+
sensors_meta={"LIDAR_TOP": {"modality": "lidar", "frame_id": "LIDAR_TOP",
|
|
116
|
+
"point_format": {"fields": ["x", "y", "z"],
|
|
117
|
+
"types": ["float32"] * 3,
|
|
118
|
+
"bytes_per_point": 12}}},
|
|
119
|
+
calibrated=[{
|
|
120
|
+
"calibrated_sensor_token": "cs-0", "channel": "LIDAR_TOP",
|
|
121
|
+
"translation": {"x": 0, "y": 0, "z": 1.5},
|
|
122
|
+
"rotation": {"qx": 0, "qy": 0, "qz": 0, "qw": 1},
|
|
123
|
+
"intrinsics": None, "distortion": None,
|
|
124
|
+
}],
|
|
125
|
+
)
|
|
126
|
+
loader = ClipLoader(tmp_path)
|
|
127
|
+
assert loader.annotation_frame_id == "LIDAR_TOP"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_loader_annotation_frame_id_back_compat_default(tmp_path):
|
|
131
|
+
"""Pre-v0.6 clip with no annotation_frame_id falls back to base_link."""
|
|
132
|
+
_write_clip(
|
|
133
|
+
tmp_path,
|
|
134
|
+
schema_version="0.5",
|
|
135
|
+
annotation_frame_id=None,
|
|
136
|
+
sensors_meta={"lidar": {"modality": "lidar"}},
|
|
137
|
+
calibrated=[{
|
|
138
|
+
"calibrated_sensor_token": "cs-0", "channel": "lidar",
|
|
139
|
+
"translation": {"x": 0, "y": 0, "z": 0},
|
|
140
|
+
"rotation": {"qx": 0, "qy": 0, "qz": 0, "qw": 1},
|
|
141
|
+
"intrinsics": None, "distortion": None,
|
|
142
|
+
}],
|
|
143
|
+
)
|
|
144
|
+
loader = ClipLoader(tmp_path)
|
|
145
|
+
assert loader.annotation_frame_id == "base_link"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_loader_frame_id_for_channel_explicit(tmp_path):
|
|
149
|
+
_write_clip(
|
|
150
|
+
tmp_path,
|
|
151
|
+
schema_version="0.6",
|
|
152
|
+
annotation_frame_id="base_link",
|
|
153
|
+
sensors_meta={"lidar": {"modality": "lidar", "frame_id": "base_link"}},
|
|
154
|
+
calibrated=[{
|
|
155
|
+
"calibrated_sensor_token": "cs-0", "channel": "lidar",
|
|
156
|
+
"translation": {"x": 0, "y": 0, "z": 0},
|
|
157
|
+
"rotation": {"qx": 0, "qy": 0, "qz": 0, "qw": 1},
|
|
158
|
+
"intrinsics": None, "distortion": None,
|
|
159
|
+
}],
|
|
160
|
+
)
|
|
161
|
+
loader = ClipLoader(tmp_path)
|
|
162
|
+
assert loader.frame_id_for_channel("lidar") == "base_link"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_loader_frame_id_for_channel_fallback_to_channel_name(tmp_path):
|
|
166
|
+
"""Pre-v0.6 clip falls back to channel ≡ frame_id convention."""
|
|
167
|
+
_write_clip(
|
|
168
|
+
tmp_path,
|
|
169
|
+
schema_version="0.5",
|
|
170
|
+
annotation_frame_id=None,
|
|
171
|
+
sensors_meta={"LIDAR_TOP": {"modality": "lidar"}},
|
|
172
|
+
calibrated=[{
|
|
173
|
+
"calibrated_sensor_token": "cs-0", "channel": "LIDAR_TOP",
|
|
174
|
+
"translation": {"x": 0, "y": 0, "z": 0},
|
|
175
|
+
"rotation": {"qx": 0, "qy": 0, "qz": 0, "qw": 1},
|
|
176
|
+
"intrinsics": None, "distortion": None,
|
|
177
|
+
}],
|
|
178
|
+
)
|
|
179
|
+
loader = ClipLoader(tmp_path)
|
|
180
|
+
assert loader.frame_id_for_channel("LIDAR_TOP") == "LIDAR_TOP"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# --- Loader: annotations_for_sample with arbitrary frame_id ---
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_annotations_for_sample_in_storage_frame_no_op(tmp_path):
|
|
187
|
+
"""Requesting the storage frame returns annotations unchanged."""
|
|
188
|
+
ann = {
|
|
189
|
+
"annotation_token": "a-0", "sample_token": "s-0", "instance_token": "i-0",
|
|
190
|
+
"category": "car",
|
|
191
|
+
"bbox_3d": {
|
|
192
|
+
"translation": {"x": 5.0, "y": 0.0, "z": 0.0},
|
|
193
|
+
"size": {"length": 4.0, "width": 2.0, "height": 1.5},
|
|
194
|
+
"rotation": _q_from_yaw(0.0),
|
|
195
|
+
},
|
|
196
|
+
"num_lidar_pts": 100, "visibility": "4", "velocity": None,
|
|
197
|
+
"attributes": [], "prev": "", "next": "", "score": None,
|
|
198
|
+
}
|
|
199
|
+
_write_clip(
|
|
200
|
+
tmp_path,
|
|
201
|
+
schema_version="0.6",
|
|
202
|
+
annotation_frame_id="LIDAR_TOP",
|
|
203
|
+
sensors_meta={"LIDAR_TOP": {"modality": "lidar", "frame_id": "LIDAR_TOP",
|
|
204
|
+
"point_format": {"fields": ["x", "y", "z"],
|
|
205
|
+
"types": ["float32"] * 3,
|
|
206
|
+
"bytes_per_point": 12}}},
|
|
207
|
+
calibrated=[{
|
|
208
|
+
"calibrated_sensor_token": "cs-0", "channel": "LIDAR_TOP",
|
|
209
|
+
"translation": {"x": 0, "y": 0, "z": 1.5},
|
|
210
|
+
"rotation": {"qx": 0, "qy": 0, "qz": 0, "qw": 1},
|
|
211
|
+
"intrinsics": None, "distortion": None,
|
|
212
|
+
}],
|
|
213
|
+
annotations=[ann],
|
|
214
|
+
)
|
|
215
|
+
loader = ClipLoader(tmp_path)
|
|
216
|
+
[a] = loader.annotations_for_sample("s-0", frame="LIDAR_TOP")
|
|
217
|
+
np.testing.assert_allclose(a.translation, [5.0, 0.0, 0.0])
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_annotations_for_sample_lidar_top_to_base_link(tmp_path):
|
|
221
|
+
"""Annotation in LIDAR_TOP with z-offset 1.5 → base_link adds 1.5 to z."""
|
|
222
|
+
ann = {
|
|
223
|
+
"annotation_token": "a-0", "sample_token": "s-0", "instance_token": "i-0",
|
|
224
|
+
"category": "car",
|
|
225
|
+
"bbox_3d": {
|
|
226
|
+
"translation": {"x": 5.0, "y": 0.0, "z": 0.0},
|
|
227
|
+
"size": {"length": 4.0, "width": 2.0, "height": 1.5},
|
|
228
|
+
"rotation": _q_from_yaw(0.0),
|
|
229
|
+
},
|
|
230
|
+
"num_lidar_pts": 100, "visibility": "4", "velocity": None,
|
|
231
|
+
"attributes": [], "prev": "", "next": "", "score": None,
|
|
232
|
+
}
|
|
233
|
+
_write_clip(
|
|
234
|
+
tmp_path,
|
|
235
|
+
schema_version="0.6",
|
|
236
|
+
annotation_frame_id="LIDAR_TOP",
|
|
237
|
+
sensors_meta={"LIDAR_TOP": {"modality": "lidar", "frame_id": "LIDAR_TOP",
|
|
238
|
+
"point_format": {"fields": ["x", "y", "z"],
|
|
239
|
+
"types": ["float32"] * 3,
|
|
240
|
+
"bytes_per_point": 12}}},
|
|
241
|
+
calibrated=[{
|
|
242
|
+
"calibrated_sensor_token": "cs-0", "channel": "LIDAR_TOP",
|
|
243
|
+
"translation": {"x": 0, "y": 0, "z": 1.5},
|
|
244
|
+
"rotation": {"qx": 0, "qy": 0, "qz": 0, "qw": 1},
|
|
245
|
+
"intrinsics": None, "distortion": None,
|
|
246
|
+
}],
|
|
247
|
+
annotations=[ann],
|
|
248
|
+
)
|
|
249
|
+
loader = ClipLoader(tmp_path)
|
|
250
|
+
[a] = loader.annotations_for_sample("s-0", frame="base_link")
|
|
251
|
+
np.testing.assert_allclose(a.translation, [5.0, 0.0, 1.5])
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_annotations_for_sample_unknown_frame_id_raises(tmp_path):
|
|
255
|
+
_write_clip(
|
|
256
|
+
tmp_path,
|
|
257
|
+
schema_version="0.6",
|
|
258
|
+
annotation_frame_id="base_link",
|
|
259
|
+
sensors_meta={"lidar": {"modality": "lidar", "frame_id": "base_link"}},
|
|
260
|
+
calibrated=[{
|
|
261
|
+
"calibrated_sensor_token": "cs-0", "channel": "lidar",
|
|
262
|
+
"translation": {"x": 0, "y": 0, "z": 0},
|
|
263
|
+
"rotation": {"qx": 0, "qy": 0, "qz": 0, "qw": 1},
|
|
264
|
+
"intrinsics": None, "distortion": None,
|
|
265
|
+
}],
|
|
266
|
+
annotations=[{
|
|
267
|
+
"annotation_token": "a-0", "sample_token": "s-0", "instance_token": "i-0",
|
|
268
|
+
"category": "car",
|
|
269
|
+
"bbox_3d": {"translation": {"x": 0, "y": 0, "z": 0},
|
|
270
|
+
"size": {"length": 1, "width": 1, "height": 1},
|
|
271
|
+
"rotation": _q_from_yaw(0.0)},
|
|
272
|
+
"num_lidar_pts": 0, "visibility": "", "velocity": None,
|
|
273
|
+
"attributes": [], "prev": "", "next": "", "score": None,
|
|
274
|
+
}],
|
|
275
|
+
)
|
|
276
|
+
loader = ClipLoader(tmp_path)
|
|
277
|
+
with pytest.raises(ValueError, match="Invalid frame"):
|
|
278
|
+
loader.annotations_for_sample("s-0", frame="never_exists_frame")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# --- TransformChain.get_ego_from_frame_id ---
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def test_transform_chain_ego_from_frame_id_base_link_is_identity(tmp_path):
|
|
285
|
+
_write_clip(
|
|
286
|
+
tmp_path,
|
|
287
|
+
schema_version="0.6",
|
|
288
|
+
annotation_frame_id="base_link",
|
|
289
|
+
sensors_meta={"lidar": {"modality": "lidar", "frame_id": "base_link"}},
|
|
290
|
+
calibrated=[{
|
|
291
|
+
"calibrated_sensor_token": "cs-0", "channel": "lidar",
|
|
292
|
+
"translation": {"x": 0, "y": 0, "z": 0},
|
|
293
|
+
"rotation": {"qx": 0, "qy": 0, "qz": 0, "qw": 1},
|
|
294
|
+
"intrinsics": None, "distortion": None,
|
|
295
|
+
}],
|
|
296
|
+
)
|
|
297
|
+
loader = ClipLoader(tmp_path)
|
|
298
|
+
from x4d_devkit.core.transform import TransformChain
|
|
299
|
+
chain = TransformChain(loader)
|
|
300
|
+
T = chain.get_ego_from_frame_id("base_link")
|
|
301
|
+
np.testing.assert_allclose(T.translation, [0, 0, 0])
|
|
302
|
+
np.testing.assert_allclose(T.rotation, np.eye(3))
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def test_transform_chain_ego_from_frame_id_resolves_via_sensor_frame_id(tmp_path):
|
|
306
|
+
"""When a sensor declares frame_id != channel, lookup uses frame_id."""
|
|
307
|
+
_write_clip(
|
|
308
|
+
tmp_path,
|
|
309
|
+
schema_version="0.6",
|
|
310
|
+
annotation_frame_id="base_link",
|
|
311
|
+
sensors_meta={"recon_pcd": {"modality": "lidar", "frame_id": "camera-front"}},
|
|
312
|
+
calibrated=[{
|
|
313
|
+
"calibrated_sensor_token": "cs-0", "channel": "recon_pcd",
|
|
314
|
+
"translation": {"x": 1.0, "y": 0, "z": 0},
|
|
315
|
+
"rotation": {"qx": 0, "qy": 0, "qz": 0, "qw": 1},
|
|
316
|
+
"intrinsics": None, "distortion": None,
|
|
317
|
+
}],
|
|
318
|
+
)
|
|
319
|
+
loader = ClipLoader(tmp_path)
|
|
320
|
+
from x4d_devkit.core.transform import TransformChain
|
|
321
|
+
chain = TransformChain(loader)
|
|
322
|
+
T = chain.get_ego_from_frame_id("camera-front")
|
|
323
|
+
np.testing.assert_allclose(T.translation, [1.0, 0, 0])
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# --- Validator: schema 0.6 strict checks ---
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def test_validate_v06_missing_annotation_frame_id_errors(tmp_path):
|
|
330
|
+
_write_clip(
|
|
331
|
+
tmp_path,
|
|
332
|
+
schema_version="0.6",
|
|
333
|
+
annotation_frame_id=None, # missing on purpose
|
|
334
|
+
sensors_meta={"lidar": {"modality": "lidar", "frame_id": "base_link"}},
|
|
335
|
+
calibrated=[{
|
|
336
|
+
"calibrated_sensor_token": "cs-0", "channel": "lidar",
|
|
337
|
+
"translation": {"x": 0, "y": 0, "z": 0},
|
|
338
|
+
"rotation": {"qx": 0, "qy": 0, "qz": 0, "qw": 1},
|
|
339
|
+
"intrinsics": None, "distortion": None,
|
|
340
|
+
}],
|
|
341
|
+
)
|
|
342
|
+
report = validate_clip(tmp_path)
|
|
343
|
+
assert any(i.check == "annotation_frame_id" for i in report.errors)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def test_validate_v06_missing_sensor_frame_id_errors(tmp_path):
|
|
347
|
+
_write_clip(
|
|
348
|
+
tmp_path,
|
|
349
|
+
schema_version="0.6",
|
|
350
|
+
annotation_frame_id="base_link",
|
|
351
|
+
sensors_meta={"lidar": {"modality": "lidar"}}, # no frame_id
|
|
352
|
+
calibrated=[{
|
|
353
|
+
"calibrated_sensor_token": "cs-0", "channel": "lidar",
|
|
354
|
+
"translation": {"x": 0, "y": 0, "z": 0},
|
|
355
|
+
"rotation": {"qx": 0, "qy": 0, "qz": 0, "qw": 1},
|
|
356
|
+
"intrinsics": None, "distortion": None,
|
|
357
|
+
}],
|
|
358
|
+
)
|
|
359
|
+
report = validate_clip(tmp_path)
|
|
360
|
+
assert any(i.check == "sensor_frame_id" for i in report.errors)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def test_validate_v06_unresolvable_annotation_frame_errors(tmp_path):
|
|
364
|
+
_write_clip(
|
|
365
|
+
tmp_path,
|
|
366
|
+
schema_version="0.6",
|
|
367
|
+
annotation_frame_id="non_existent_frame",
|
|
368
|
+
sensors_meta={"lidar": {"modality": "lidar", "frame_id": "base_link"}},
|
|
369
|
+
calibrated=[{
|
|
370
|
+
"calibrated_sensor_token": "cs-0", "channel": "lidar",
|
|
371
|
+
"translation": {"x": 0, "y": 0, "z": 0},
|
|
372
|
+
"rotation": {"qx": 0, "qy": 0, "qz": 0, "qw": 1},
|
|
373
|
+
"intrinsics": None, "distortion": None,
|
|
374
|
+
}],
|
|
375
|
+
)
|
|
376
|
+
report = validate_clip(tmp_path)
|
|
377
|
+
msgs = [i.message for i in report.errors if i.check == "annotation_frame_id"]
|
|
378
|
+
assert any("does not resolve" in m for m in msgs)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def test_validate_v06_passes_with_lidar_top_annotation_frame(tmp_path):
|
|
382
|
+
"""Scenario 1 (nuScenes-style): annotation_frame_id == sensor's own frame_id."""
|
|
383
|
+
ann = {
|
|
384
|
+
"annotation_token": "a-0", "sample_token": "s-0", "instance_token": "i-0",
|
|
385
|
+
"category": "car",
|
|
386
|
+
"bbox_3d": {"translation": {"x": 5, "y": 0, "z": 0},
|
|
387
|
+
"size": {"length": 4, "width": 2, "height": 1.5},
|
|
388
|
+
"rotation": _q_from_yaw(0.0)},
|
|
389
|
+
"num_lidar_pts": 50, "visibility": "4", "velocity": None,
|
|
390
|
+
"attributes": [], "prev": "", "next": "", "score": None,
|
|
391
|
+
}
|
|
392
|
+
_write_clip(
|
|
393
|
+
tmp_path,
|
|
394
|
+
schema_version="0.6",
|
|
395
|
+
annotation_frame_id="LIDAR_TOP",
|
|
396
|
+
sensors_meta={"LIDAR_TOP": {"modality": "lidar", "frame_id": "LIDAR_TOP",
|
|
397
|
+
"point_format": {"fields": ["x", "y", "z", "intensity", "t"],
|
|
398
|
+
"types": ["float32"] * 5,
|
|
399
|
+
"bytes_per_point": 20}}},
|
|
400
|
+
calibrated=[{
|
|
401
|
+
"calibrated_sensor_token": "cs-0", "channel": "LIDAR_TOP",
|
|
402
|
+
"translation": {"x": 0, "y": 0, "z": 1.5},
|
|
403
|
+
"rotation": {"qx": 0, "qy": 0, "qz": 0, "qw": 1},
|
|
404
|
+
"intrinsics": None, "distortion": None,
|
|
405
|
+
}],
|
|
406
|
+
annotations=[ann],
|
|
407
|
+
)
|
|
408
|
+
pcd_path = tmp_path / "samples/x/0.bin"
|
|
409
|
+
pcd_path.parent.mkdir(parents=True, exist_ok=True)
|
|
410
|
+
np.zeros(5, dtype=np.float32).tofile(pcd_path)
|
|
411
|
+
|
|
412
|
+
report = validate_clip(tmp_path)
|
|
413
|
+
schema_errors = [i for i in report.errors if i.check in ("annotation_frame_id", "sensor_frame_id")]
|
|
414
|
+
assert schema_errors == [], f"unexpected schema errors: {schema_errors}"
|
|
@@ -23,6 +23,12 @@ from x4d_devkit.core.transform import Transform, TransformChain, rotation_matrix
|
|
|
23
23
|
|
|
24
24
|
FrameType = Literal["sensor", "ego", "world"]
|
|
25
25
|
_VALID_FRAMES = {"sensor", "ego", "world"}
|
|
26
|
+
# In annotation/transform APIs the user may pass the literal aliases above
|
|
27
|
+
# OR any frame_id string that resolves in the calibration tree. We resolve
|
|
28
|
+
# "ego" and "base_link" to the ego (vehicle body) frame; "world" composes
|
|
29
|
+
# with ego_pose; any other string is treated as a sensor frame_id and looked
|
|
30
|
+
# up via the calibration tree.
|
|
31
|
+
_EGO_ALIASES = {"ego", "base_link"}
|
|
26
32
|
|
|
27
33
|
|
|
28
34
|
class SchemaVersionMismatch(ValueError):
|
|
@@ -58,14 +64,24 @@ class ClipLoader:
|
|
|
58
64
|
loader = ClipLoader("/data/clips/my_clip")
|
|
59
65
|
print(loader.meta.clip_id)
|
|
60
66
|
|
|
61
|
-
#
|
|
67
|
+
# Find lidar channels by modality — never hardcode "lidar" / "LIDAR_TOP".
|
|
68
|
+
# The directory/channel name is producer-defined: nuScenes-converted clips
|
|
69
|
+
# use "LIDAR_TOP", X-4D platform fused output uses "lidar", reconstruction
|
|
70
|
+
# pipelines may use any name. Always look up via meta.sensors.
|
|
71
|
+
lidar_channels = [
|
|
72
|
+
ch for ch, info in loader.meta.sensors.items()
|
|
73
|
+
if info.modality == "lidar"
|
|
74
|
+
]
|
|
75
|
+
|
|
62
76
|
for sample in loader.samples:
|
|
63
77
|
for sd in loader.sample_data_for_sample(sample.token):
|
|
64
|
-
if
|
|
78
|
+
if sd.channel in lidar_channels:
|
|
65
79
|
pts = loader.load_point_cloud(sd, frame="ego")
|
|
66
80
|
|
|
67
|
-
# Get annotations in
|
|
68
|
-
anns = loader.annotations_for_sample(sample.token
|
|
81
|
+
# Get annotations in their stored frame (loader.annotation_frame_id)
|
|
82
|
+
anns = loader.annotations_for_sample(sample.token)
|
|
83
|
+
# Or transform to a specific frame_id from the calibration tree
|
|
84
|
+
anns_in_top_lidar = loader.annotations_for_sample(sample.token, frame="LIDAR_TOP")
|
|
69
85
|
"""
|
|
70
86
|
|
|
71
87
|
def __init__(
|
|
@@ -137,6 +153,7 @@ class ClipLoader:
|
|
|
137
153
|
num_keyframes=d["keyframe_count"],
|
|
138
154
|
num_sweeps=d["sweep_count"],
|
|
139
155
|
sensors=sensors,
|
|
156
|
+
annotation_frame_id=d.get("annotation_frame_id"),
|
|
140
157
|
)
|
|
141
158
|
|
|
142
159
|
def _load_calibrated_sensors(self) -> list[CalibratedSensor]:
|
|
@@ -263,6 +280,31 @@ class ClipLoader:
|
|
|
263
280
|
"""Shortcut for ``self.meta.schema_version`` — the X-4D format version this clip was written in."""
|
|
264
281
|
return self._meta.schema_version
|
|
265
282
|
|
|
283
|
+
@property
|
|
284
|
+
def annotation_frame_id(self) -> str:
|
|
285
|
+
"""The vehicle-local frame_id annotations are stored in.
|
|
286
|
+
|
|
287
|
+
Returns ``meta.annotation_frame_id`` if explicitly set (v0.6+ clips).
|
|
288
|
+
Pre-v0.6 clips fall back to ``"base_link"`` — the per-sample ego frame
|
|
289
|
+
all earlier loader code already assumed (``annotations_for_sample(frame="ego")``
|
|
290
|
+
was a no-op transform).
|
|
291
|
+
"""
|
|
292
|
+
if self._meta.annotation_frame_id is not None:
|
|
293
|
+
return self._meta.annotation_frame_id
|
|
294
|
+
return "base_link"
|
|
295
|
+
|
|
296
|
+
def frame_id_for_channel(self, channel: str) -> str:
|
|
297
|
+
"""Resolve the ROS frame_id of a sensor channel.
|
|
298
|
+
|
|
299
|
+
Looks up ``meta.sensors[channel].frame_id`` (v0.6+). Falls back to the
|
|
300
|
+
channel name itself for older clips, per the v0.4 ``channel ≡ frame_id``
|
|
301
|
+
convention.
|
|
302
|
+
"""
|
|
303
|
+
info = self._meta.sensors.get(channel)
|
|
304
|
+
if info is not None and info.frame_id is not None:
|
|
305
|
+
return info.frame_id
|
|
306
|
+
return channel
|
|
307
|
+
|
|
266
308
|
def require_schema_version(self, expected: "str | set[str]") -> None:
|
|
267
309
|
"""Raise ``SchemaVersionMismatch`` if the clip's schema_version isn't in ``expected``.
|
|
268
310
|
|
|
@@ -355,34 +397,74 @@ class ClipLoader:
|
|
|
355
397
|
def annotations_for_sample(
|
|
356
398
|
self,
|
|
357
399
|
sample_token: str,
|
|
358
|
-
frame:
|
|
400
|
+
frame: str = "ego",
|
|
359
401
|
) -> list[Annotation]:
|
|
360
|
-
"""All annotations for a given sample.
|
|
402
|
+
"""All annotations for a given sample, transformed to the requested frame.
|
|
361
403
|
|
|
362
404
|
Args:
|
|
363
405
|
sample_token: The sample token.
|
|
364
|
-
frame: Target coordinate frame.
|
|
365
|
-
- "ego" (default
|
|
366
|
-
|
|
367
|
-
-
|
|
368
|
-
|
|
369
|
-
|
|
406
|
+
frame: Target coordinate frame. Accepted values:
|
|
407
|
+
- ``"ego"`` / ``"base_link"`` (default): vehicle body frame.
|
|
408
|
+
- ``"world"``: clip-local world frame (composes with ego_pose).
|
|
409
|
+
- any sensor's ROS frame_id present in the calibration tree
|
|
410
|
+
(e.g. ``"LIDAR_TOP"``, ``"camera-front"``).
|
|
411
|
+
- ``"sensor"`` is rejected (ambiguous — no single sensor implied).
|
|
370
412
|
|
|
371
413
|
Returns:
|
|
372
414
|
List of Annotation objects in the requested frame.
|
|
373
415
|
"""
|
|
374
|
-
if frame not in _VALID_FRAMES:
|
|
375
|
-
raise ValueError(f"Invalid frame '{frame}', must be one of {_VALID_FRAMES}")
|
|
376
416
|
if frame == "sensor":
|
|
377
|
-
raise ValueError(
|
|
417
|
+
raise ValueError(
|
|
418
|
+
"frame='sensor' is ambiguous for annotations; pass an explicit "
|
|
419
|
+
"frame_id (e.g. 'LIDAR_TOP') or use 'ego' / 'base_link' / 'world'."
|
|
420
|
+
)
|
|
378
421
|
|
|
379
422
|
anns = self._ann_by_sample.get(sample_token, [])
|
|
380
|
-
if
|
|
423
|
+
if not anns:
|
|
424
|
+
return anns
|
|
425
|
+
|
|
426
|
+
T = self._transform_for_annotation_target(sample_token, frame)
|
|
427
|
+
if T is None:
|
|
381
428
|
return anns
|
|
429
|
+
return [self._transform_annotation(a, T) for a in anns]
|
|
430
|
+
|
|
431
|
+
def _transform_for_annotation_target(
|
|
432
|
+
self, sample_token: str, target_frame: str
|
|
433
|
+
) -> Transform | None:
|
|
434
|
+
"""Build the transform from the clip's annotation frame to ``target_frame``.
|
|
382
435
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
436
|
+
Returns ``None`` when the requested frame matches the storage frame
|
|
437
|
+
(no transform needed).
|
|
438
|
+
"""
|
|
439
|
+
ann_frame = self.annotation_frame_id # e.g. "base_link" or "LIDAR_TOP"
|
|
440
|
+
if target_frame == ann_frame:
|
|
441
|
+
return None
|
|
442
|
+
if target_frame in _EGO_ALIASES and ann_frame in _EGO_ALIASES:
|
|
443
|
+
return None
|
|
444
|
+
|
|
445
|
+
chain = self._get_transform_chain()
|
|
446
|
+
# T(ego ← annotation_frame): identity if annotation frame is ego/base_link;
|
|
447
|
+
# else look up the sensor whose frame_id == ann_frame.
|
|
448
|
+
if ann_frame in _EGO_ALIASES:
|
|
449
|
+
ego_from_ann = Transform.identity()
|
|
450
|
+
else:
|
|
451
|
+
ego_from_ann = chain.get_ego_from_frame_id(ann_frame)
|
|
452
|
+
|
|
453
|
+
# T(target ← ego)
|
|
454
|
+
if target_frame in _EGO_ALIASES:
|
|
455
|
+
target_from_ego = Transform.identity()
|
|
456
|
+
elif target_frame == "world":
|
|
457
|
+
target_from_ego = self._get_world_from_ego_for_sample(sample_token)
|
|
458
|
+
else:
|
|
459
|
+
try:
|
|
460
|
+
ego_from_target = chain.get_ego_from_frame_id(target_frame)
|
|
461
|
+
except KeyError as e:
|
|
462
|
+
raise ValueError(
|
|
463
|
+
f"Invalid frame {target_frame!r} for annotations: {e}"
|
|
464
|
+
) from None
|
|
465
|
+
target_from_ego = ego_from_target.inverse
|
|
466
|
+
|
|
467
|
+
return target_from_ego @ ego_from_ann
|
|
386
468
|
|
|
387
469
|
def load_point_cloud(
|
|
388
470
|
self,
|
|
@@ -72,6 +72,9 @@ class SensorInfo:
|
|
|
72
72
|
|
|
73
73
|
Attributes:
|
|
74
74
|
modality: ``"lidar"`` or ``"camera"``.
|
|
75
|
+
frame_id: ROS-style frame_id of this channel's stored data. Required by
|
|
76
|
+
v0.6 schema; ``None`` on older clips. For fused outputs the producer
|
|
77
|
+
should set this to the fusion target frame (e.g. ``"base_link"``).
|
|
75
78
|
point_format: Binary point layout (LiDAR only; ``None`` for cameras).
|
|
76
79
|
resolution: ``(width, height)`` in pixels (cameras only; ``None`` for LiDAR).
|
|
77
80
|
extras: Any additional fields the producer wrote into the sensor dict.
|
|
@@ -79,6 +82,7 @@ class SensorInfo:
|
|
|
79
82
|
"""
|
|
80
83
|
|
|
81
84
|
modality: str
|
|
85
|
+
frame_id: str | None = None
|
|
82
86
|
point_format: PointFormat | None = None
|
|
83
87
|
resolution: tuple[int, int] | None = None
|
|
84
88
|
extras: dict[str, Any] = field(default_factory=dict)
|
|
@@ -86,14 +90,16 @@ class SensorInfo:
|
|
|
86
90
|
@classmethod
|
|
87
91
|
def from_dict(cls, d: dict[str, Any]) -> SensorInfo:
|
|
88
92
|
modality = d["modality"]
|
|
93
|
+
frame_id = d.get("frame_id")
|
|
89
94
|
pf_raw = d.get("point_format")
|
|
90
95
|
point_format = PointFormat.from_dict(pf_raw) if pf_raw is not None else None
|
|
91
96
|
res_raw = d.get("resolution")
|
|
92
97
|
resolution = (int(res_raw[0]), int(res_raw[1])) if res_raw is not None else None
|
|
93
|
-
known = {"modality", "point_format", "resolution"}
|
|
98
|
+
known = {"modality", "frame_id", "point_format", "resolution"}
|
|
94
99
|
extras = {k: v for k, v in d.items() if k not in known}
|
|
95
100
|
return cls(
|
|
96
101
|
modality=modality,
|
|
102
|
+
frame_id=frame_id,
|
|
97
103
|
point_format=point_format,
|
|
98
104
|
resolution=resolution,
|
|
99
105
|
extras=extras,
|
|
@@ -101,6 +107,8 @@ class SensorInfo:
|
|
|
101
107
|
|
|
102
108
|
def to_dict(self) -> dict[str, Any]:
|
|
103
109
|
out: dict[str, Any] = {"modality": self.modality}
|
|
110
|
+
if self.frame_id is not None:
|
|
111
|
+
out["frame_id"] = self.frame_id
|
|
104
112
|
if self.point_format is not None:
|
|
105
113
|
out["point_format"] = self.point_format.to_dict()
|
|
106
114
|
if self.resolution is not None:
|
|
@@ -125,6 +133,10 @@ class ClipMeta:
|
|
|
125
133
|
sensors: Per-channel sensor metadata, keyed by channel name. Each
|
|
126
134
|
value is a ``SensorInfo`` carrying modality + (for LiDAR) the
|
|
127
135
|
binary ``PointFormat`` describing the .bin file layout.
|
|
136
|
+
annotation_frame_id: The ROS frame_id annotations are stored in.
|
|
137
|
+
Required by v0.6 schema. ``None`` on older clips (callers can
|
|
138
|
+
consult ``ClipLoader.annotation_frame_id`` for a back-compat
|
|
139
|
+
fallback that infers ``"base_link"`` for v0.5 clips).
|
|
128
140
|
"""
|
|
129
141
|
|
|
130
142
|
clip_id: str
|
|
@@ -136,6 +148,7 @@ class ClipMeta:
|
|
|
136
148
|
num_keyframes: int
|
|
137
149
|
num_sweeps: int
|
|
138
150
|
sensors: dict[str, SensorInfo]
|
|
151
|
+
annotation_frame_id: str | None = None
|
|
139
152
|
|
|
140
153
|
|
|
141
154
|
@dataclass(frozen=True)
|
|
@@ -245,27 +258,31 @@ class SampleData:
|
|
|
245
258
|
|
|
246
259
|
@dataclass(frozen=True)
|
|
247
260
|
class Annotation:
|
|
248
|
-
"""A 3D bounding box annotation in
|
|
261
|
+
"""A 3D bounding box annotation in the clip's vehicle-local annotation frame.
|
|
249
262
|
|
|
250
|
-
Annotations are
|
|
251
|
-
|
|
252
|
-
``
|
|
253
|
-
|
|
254
|
-
|
|
263
|
+
Annotations are stored in whichever vehicle-local frame the producer
|
|
264
|
+
declared via ``meta.annotation_frame_id`` (v0.6+) — typically ``"base_link"``
|
|
265
|
+
for fused-lidar clips, ``"LIDAR_TOP"`` for nuScenes-style top-lidar clips,
|
|
266
|
+
or another sensor's frame_id. The frame is rigid relative to ``base_link``
|
|
267
|
+
via calibration, so per-sample timing is determined by ``sample_token``.
|
|
268
|
+
For v0.5 clips without an explicit field, the convention is per-sample ego
|
|
269
|
+
frame (== ``base_link``). Use ``ClipLoader.annotations_for_sample(..., frame=...)``
|
|
270
|
+
to transform to ``"world"`` or any other frame_id in the calibration tree.
|
|
255
271
|
|
|
256
272
|
Attributes:
|
|
257
273
|
token: Unique annotation token.
|
|
258
274
|
sample_token: Token of the sample this annotation belongs to.
|
|
259
275
|
instance_token: Token of the tracked object instance.
|
|
260
276
|
category: Object category string (e.g. "car", "pedestrian").
|
|
261
|
-
translation: (3,) float64 array — box center [x, y, z] in
|
|
277
|
+
translation: (3,) float64 array — box center [x, y, z] in the
|
|
278
|
+
annotation frame at this sample's timestamp.
|
|
262
279
|
size: (3,) float64 array — box dimensions [length, width, height] in meters.
|
|
263
280
|
rotation: (4,) float64 array — box orientation as quaternion [qx, qy, qz, qw],
|
|
264
|
-
relative to
|
|
281
|
+
relative to the annotation frame.
|
|
265
282
|
num_lidar_pts: Number of LiDAR points inside this box (real-counted, ≥ 0).
|
|
266
283
|
visibility: Visibility level as string ("1" to "4", or "" if unknown).
|
|
267
|
-
velocity: (3,) float64 array — velocity [vx, vy, vz] in m/s,
|
|
268
|
-
(forward = +x, left = +y), or None.
|
|
284
|
+
velocity: (3,) float64 array — velocity [vx, vy, vz] in m/s, in the
|
|
285
|
+
annotation frame (forward = +x, left = +y), or None.
|
|
269
286
|
attributes: List of attribute strings (e.g. ["vehicle.moving"]).
|
|
270
287
|
prev: Token of previous annotation of same instance, or "" if first.
|
|
271
288
|
next: Token of next annotation of same instance, or "" if last.
|
|
@@ -191,6 +191,14 @@ class TransformChain:
|
|
|
191
191
|
def __init__(self, loader: ClipLoader) -> None:
|
|
192
192
|
self._loader = loader
|
|
193
193
|
self._cs_by_channel = {cs.channel: cs for cs in loader.calibrated_sensors}
|
|
194
|
+
# Map ROS frame_id → channel: prefer explicit meta.sensors[ch].frame_id
|
|
195
|
+
# (v0.6+); fall back to channel-name equality for older clips.
|
|
196
|
+
self._channel_by_frame_id: dict[str, str] = {}
|
|
197
|
+
for ch, info in loader.meta.sensors.items():
|
|
198
|
+
fid = info.frame_id if info.frame_id is not None else ch
|
|
199
|
+
self._channel_by_frame_id[fid] = ch
|
|
200
|
+
for ch in self._cs_by_channel:
|
|
201
|
+
self._channel_by_frame_id.setdefault(ch, ch)
|
|
194
202
|
|
|
195
203
|
def get_ego_from_sensor(self, channel: str) -> Transform:
|
|
196
204
|
"""Get the static sensor-to-ego transform for a channel.
|
|
@@ -206,6 +214,33 @@ class TransformChain:
|
|
|
206
214
|
cs = self._cs_by_channel[channel]
|
|
207
215
|
return Transform.from_quat(cs.translation, cs.rotation)
|
|
208
216
|
|
|
217
|
+
def get_ego_from_frame_id(self, frame_id: str) -> Transform:
|
|
218
|
+
"""Get the static frame_id-to-ego transform.
|
|
219
|
+
|
|
220
|
+
Resolves ``frame_id`` to the calibrated channel that declares it
|
|
221
|
+
(via ``meta.sensors[ch].frame_id``, falling back to channel-name
|
|
222
|
+
equality for older clips). For ``frame_id == "base_link"`` this is
|
|
223
|
+
the identity transform.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
frame_id: ROS frame_id (e.g. ``"LIDAR_TOP"``, ``"camera-front"``).
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
Transform from the named frame to the ego (base_link) frame.
|
|
230
|
+
|
|
231
|
+
Raises:
|
|
232
|
+
KeyError: if no sensor declares ``frame_id``.
|
|
233
|
+
"""
|
|
234
|
+
if frame_id == "base_link":
|
|
235
|
+
return Transform.identity()
|
|
236
|
+
ch = self._channel_by_frame_id.get(frame_id)
|
|
237
|
+
if ch is None:
|
|
238
|
+
raise KeyError(
|
|
239
|
+
f"frame_id {frame_id!r} not found in calibration tree "
|
|
240
|
+
f"(known: {sorted(self._channel_by_frame_id)})"
|
|
241
|
+
)
|
|
242
|
+
return self.get_ego_from_sensor(ch)
|
|
243
|
+
|
|
209
244
|
def get_world_from_ego(self, ego_pose_token: str) -> Transform:
|
|
210
245
|
"""Get the ego-to-world transform for a specific ego pose.
|
|
211
246
|
|
|
@@ -101,6 +101,57 @@ def validate_clip(clip_dir: str | Path) -> ValidationReport:
|
|
|
101
101
|
if "meta.json" in jsons:
|
|
102
102
|
clip_id = jsons["meta.json"].get("clip_id", clip_id)
|
|
103
103
|
|
|
104
|
+
# Schema v0.6: explicit annotation_frame_id + per-sensor frame_id, both
|
|
105
|
+
# resolving against calibrated_sensor.json.
|
|
106
|
+
if "meta.json" in jsons:
|
|
107
|
+
meta = jsons["meta.json"]
|
|
108
|
+
sv = str(meta.get("schema_version", ""))
|
|
109
|
+
if sv == "0.6":
|
|
110
|
+
cs_records = jsons.get("calibrated_sensor.json", []) or []
|
|
111
|
+
cs_channels = {r.get("channel") for r in cs_records if r.get("channel")}
|
|
112
|
+
calib_frame_ids = set(cs_channels) | {"base_link"}
|
|
113
|
+
sensors = (meta.get("sensors") or {})
|
|
114
|
+
sensor_frame_ids: set[str] = set()
|
|
115
|
+
for ch, info in sensors.items():
|
|
116
|
+
fid = (info or {}).get("frame_id") if isinstance(info, dict) else None
|
|
117
|
+
if not fid:
|
|
118
|
+
issues.append(
|
|
119
|
+
ValidationIssue(
|
|
120
|
+
"sensor_frame_id",
|
|
121
|
+
"error",
|
|
122
|
+
f"sensors[{ch!r}].frame_id is required in schema 0.6",
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
continue
|
|
126
|
+
sensor_frame_ids.add(fid)
|
|
127
|
+
if ch not in cs_channels:
|
|
128
|
+
issues.append(
|
|
129
|
+
ValidationIssue(
|
|
130
|
+
"sensor_frame_id",
|
|
131
|
+
"error",
|
|
132
|
+
f"sensors[{ch!r}] has no matching calibrated_sensor record",
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
calib_frame_ids |= sensor_frame_ids
|
|
136
|
+
ann_fid = meta.get("annotation_frame_id")
|
|
137
|
+
if not ann_fid:
|
|
138
|
+
issues.append(
|
|
139
|
+
ValidationIssue(
|
|
140
|
+
"annotation_frame_id",
|
|
141
|
+
"error",
|
|
142
|
+
"meta.annotation_frame_id is required in schema 0.6",
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
elif ann_fid not in calib_frame_ids:
|
|
146
|
+
issues.append(
|
|
147
|
+
ValidationIssue(
|
|
148
|
+
"annotation_frame_id",
|
|
149
|
+
"error",
|
|
150
|
+
f"annotation_frame_id={ann_fid!r} does not resolve in calibration tree "
|
|
151
|
+
f"(known: {sorted(calib_frame_ids)})",
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
|
|
104
155
|
# If we can't parse essential files, return early
|
|
105
156
|
if "sample.json" not in jsons or "sample_data.json" not in jsons:
|
|
106
157
|
return ValidationReport(clip_id=clip_id, issues=issues)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|