x4d-devkit 0.9.0__tar.gz → 0.11.1__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.9.0/x4d_devkit.egg-info → x4d_devkit-0.11.1}/PKG-INFO +1 -1
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/pyproject.toml +1 -1
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/converters/nuscenes.py +89 -28
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/core/loader.py +14 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/core/models.py +10 -2
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/core/transform.py +89 -12
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/validation/validator.py +160 -21
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1/x4d_devkit.egg-info}/PKG-INFO +1 -1
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/LICENSE +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/README.md +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/setup.cfg +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/tests/test_box.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/tests/test_coordinate_frames.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/tests/test_detection_eval.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/tests/test_evaluate.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/tests/test_fusion.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/tests/test_loader.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/tests/test_matching.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/tests/test_metrics.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/tests/test_models.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/tests/test_nuscenes_converter.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/tests/test_token.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/tests/test_transform.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/tests/test_transform_chain.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/tests/test_v06_api_additions.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/tests/test_v06_schema.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/tests/test_validation.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/__init__.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/cli.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/client/__init__.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/client/checkpoints.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/client/client.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/client/clips.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/client/projects.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/client/test_sets.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/converters/__init__.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/core/__init__.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/core/token.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/eval/__init__.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/eval/api.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/eval/box.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/eval/detection.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/eval/matching.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/eval/metrics.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/fusion.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/validation/__init__.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/validation/report.py +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit.egg-info/SOURCES.txt +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit.egg-info/dependency_links.txt +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit.egg-info/entry_points.txt +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit.egg-info/requires.txt +0 -0
- {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/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.11.1"
|
|
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"
|
|
@@ -116,6 +116,11 @@ def compose_se3(
|
|
|
116
116
|
|
|
117
117
|
|
|
118
118
|
# --- Target sensor channels (skip radar) ---
|
|
119
|
+
#
|
|
120
|
+
# nuScenes stores channels uppercase (LIDAR_TOP, CAM_FRONT_*); X-4D clip
|
|
121
|
+
# schema v0.7 enforces ^[a-z][a-z0-9_]{0,63}$ on channel + every frame_id
|
|
122
|
+
# field. We filter against the nuScenes uppercase keys, then `_normalize`
|
|
123
|
+
# everything we emit to X-4D.
|
|
119
124
|
|
|
120
125
|
TARGET_CHANNELS = {
|
|
121
126
|
"CAM_FRONT",
|
|
@@ -127,6 +132,12 @@ TARGET_CHANNELS = {
|
|
|
127
132
|
"LIDAR_TOP",
|
|
128
133
|
}
|
|
129
134
|
|
|
135
|
+
|
|
136
|
+
def _normalize_channel(name: str) -> str:
|
|
137
|
+
"""Lowercase nuScenes channel into X-4D v0.7-compliant form
|
|
138
|
+
(e.g. ``LIDAR_TOP`` → ``lidar_top``)."""
|
|
139
|
+
return name.lower()
|
|
140
|
+
|
|
130
141
|
LIDAR_POINT_FORMAT = {
|
|
131
142
|
"fields": ["x", "y", "z", "intensity", "ring_index"],
|
|
132
143
|
"types": ["float32", "float32", "float32", "float32", "float32"],
|
|
@@ -173,18 +184,20 @@ class NuScenesConverter:
|
|
|
173
184
|
for channel, sd_token in first_sample["data"].items():
|
|
174
185
|
if channel not in TARGET_CHANNELS:
|
|
175
186
|
continue
|
|
187
|
+
x4d_ch = _normalize_channel(channel)
|
|
176
188
|
sd = self.nusc.get("sample_data", sd_token)
|
|
177
189
|
cs = self._cs_by_token[sd["calibrated_sensor_token"]]
|
|
178
190
|
sensor = self._sensor_by_token[cs["sensor_token"]]
|
|
179
191
|
if sensor["modality"] == "lidar":
|
|
180
|
-
sensors_dict[
|
|
192
|
+
sensors_dict[x4d_ch] = {
|
|
181
193
|
"modality": "lidar",
|
|
194
|
+
"frame_id": x4d_ch,
|
|
182
195
|
"point_format": LIDAR_POINT_FORMAT,
|
|
183
196
|
}
|
|
184
197
|
elif sensor["modality"] == "camera":
|
|
185
|
-
sensors_dict[
|
|
198
|
+
sensors_dict[x4d_ch] = {
|
|
186
199
|
"modality": "camera",
|
|
187
|
-
"
|
|
200
|
+
"frame_id": x4d_ch,
|
|
188
201
|
}
|
|
189
202
|
|
|
190
203
|
# Count keyframes
|
|
@@ -213,6 +226,10 @@ class NuScenesConverter:
|
|
|
213
226
|
last_sample = self.nusc.get("sample", scene["last_sample_token"])
|
|
214
227
|
duration_s = (last_sample["timestamp"] - first_sample["timestamp"]) / 1e6
|
|
215
228
|
|
|
229
|
+
# nuScenes annotates in the LIDAR_TOP frame; SLAM also runs there, so
|
|
230
|
+
# ego_pose is also expressed for that frame in clip-local world. After
|
|
231
|
+
# normalization, both are "lidar_top".
|
|
232
|
+
ann_frame_id = _normalize_channel("LIDAR_TOP")
|
|
216
233
|
meta = {
|
|
217
234
|
"clip_id": clip_id,
|
|
218
235
|
"session_id": log["logfile"],
|
|
@@ -222,20 +239,38 @@ class NuScenesConverter:
|
|
|
222
239
|
"keyframe_count": keyframe_count,
|
|
223
240
|
"sweep_count": sweep_count,
|
|
224
241
|
"sensors": sensors_dict,
|
|
242
|
+
"annotation_frame_id": ann_frame_id,
|
|
243
|
+
"ego_pose_frame_id": ann_frame_id,
|
|
225
244
|
"route_tag": None,
|
|
226
245
|
"weather": None,
|
|
227
246
|
"time_of_day": None,
|
|
228
247
|
"scene_tags": [log.get("location", "unknown")],
|
|
229
|
-
"schema_version": "0.
|
|
248
|
+
"schema_version": "0.8",
|
|
230
249
|
}
|
|
231
250
|
return clip_id, meta
|
|
232
251
|
|
|
233
252
|
def _extract_calibrated_sensors(
|
|
234
253
|
self, scene: dict, clip_id: str
|
|
235
254
|
) -> list[dict]:
|
|
236
|
-
"""Extract calibrated sensor records for target channels.
|
|
255
|
+
"""Extract calibrated sensor records for target channels.
|
|
256
|
+
|
|
257
|
+
v0.8: emits a self-reference root record for ``base_link`` (the
|
|
258
|
+
calibration tree root for nuScenes — cameras and lidar are all
|
|
259
|
+
calibrated relative to ego vehicle).
|
|
260
|
+
"""
|
|
261
|
+
# v0.8 self-reference root record (I1 invariant).
|
|
262
|
+
records = [
|
|
263
|
+
{
|
|
264
|
+
"calibrated_sensor_token": generate_token(clip_id, "cs:base_link"),
|
|
265
|
+
"channel": "base_link",
|
|
266
|
+
"parent_frame": "base_link", # self-reference == root marker
|
|
267
|
+
"translation": {"x": 0.0, "y": 0.0, "z": 0.0},
|
|
268
|
+
"rotation": {"qx": 0.0, "qy": 0.0, "qz": 0.0, "qw": 1.0},
|
|
269
|
+
"intrinsics": None,
|
|
270
|
+
"distortion": None,
|
|
271
|
+
}
|
|
272
|
+
]
|
|
237
273
|
first_sample = self.nusc.get("sample", scene["first_sample_token"])
|
|
238
|
-
records = []
|
|
239
274
|
for channel, sd_token in first_sample["data"].items():
|
|
240
275
|
if channel not in TARGET_CHANNELS:
|
|
241
276
|
continue
|
|
@@ -251,10 +286,11 @@ class NuScenesConverter:
|
|
|
251
286
|
else None
|
|
252
287
|
)
|
|
253
288
|
|
|
289
|
+
x4d_ch = _normalize_channel(channel)
|
|
254
290
|
records.append(
|
|
255
291
|
{
|
|
256
|
-
"calibrated_sensor_token": generate_token(clip_id, f"cs:{
|
|
257
|
-
"channel":
|
|
292
|
+
"calibrated_sensor_token": generate_token(clip_id, f"cs:{x4d_ch}"),
|
|
293
|
+
"channel": x4d_ch,
|
|
258
294
|
"parent_frame": parent_frame,
|
|
259
295
|
"translation": translation_to_named(cs["translation"]),
|
|
260
296
|
"rotation": quat_wxyz_to_named(cs["rotation"]),
|
|
@@ -409,7 +445,15 @@ class NuScenesConverter:
|
|
|
409
445
|
def _extract_annotations(
|
|
410
446
|
self, scene: dict, clip_id: str
|
|
411
447
|
) -> tuple[list[dict], list[dict]]:
|
|
412
|
-
"""Extract annotations and instances for the scene, in **
|
|
448
|
+
"""Extract annotations and instances for the scene, in **lidar_top frame** (v0.8).
|
|
449
|
+
|
|
450
|
+
v0.8 invariant: annotation_frame_id must equal some sensor's frame_id
|
|
451
|
+
(label-source colocation). For nuScenes that's lidar_top (the original
|
|
452
|
+
labeling source). We compose:
|
|
453
|
+
T_lidar_top←world = T_lidar_top←base_link · T_base_link←world
|
|
454
|
+
= inv(cs_lidar_extrinsic) · inv(ego_pose)
|
|
455
|
+
and apply to each annotation's world-frame box.
|
|
456
|
+
"""
|
|
413
457
|
annotations: list[dict] = []
|
|
414
458
|
instance_anns: dict[str, list[dict]] = {}
|
|
415
459
|
|
|
@@ -421,14 +465,30 @@ class NuScenesConverter:
|
|
|
421
465
|
sample_token_map[sample_token] = new_token
|
|
422
466
|
sample_token = sample["next"]
|
|
423
467
|
|
|
468
|
+
# nuScenes lidar extrinsic in base_link (ego vehicle) frame — read once;
|
|
469
|
+
# static across the scene.
|
|
470
|
+
first_sample = self.nusc.get("sample", scene["first_sample_token"])
|
|
471
|
+
first_lidar_sd = self.nusc.get("sample_data", first_sample["data"]["LIDAR_TOP"])
|
|
472
|
+
cs_lidar = self._cs_by_token[first_lidar_sd["calibrated_sensor_token"]]
|
|
473
|
+
R_base_from_lidar = quat_to_rotation_matrix(cs_lidar["rotation"])
|
|
474
|
+
t_base_from_lidar = np.asarray(cs_lidar["translation"])
|
|
475
|
+
# Inverse: lidar_top ← base_link
|
|
476
|
+
R_lidar_from_base, t_lidar_from_base = invert_se3(R_base_from_lidar, t_base_from_lidar)
|
|
477
|
+
|
|
424
478
|
sample_token = scene["first_sample_token"]
|
|
425
479
|
while sample_token:
|
|
426
480
|
sample = self.nusc.get("sample", sample_token)
|
|
427
481
|
new_sample_token = sample_token_map[sample_token]
|
|
428
482
|
|
|
429
|
-
#
|
|
430
|
-
|
|
431
|
-
|
|
483
|
+
# base_link in world for this sample (nuScenes ego_pose).
|
|
484
|
+
R_world_from_base, t_world_from_base = self._get_lidar_ego_pose_for_sample(sample)
|
|
485
|
+
# Inverse: base_link ← world
|
|
486
|
+
R_base_from_world, t_base_from_world = invert_se3(R_world_from_base, t_world_from_base)
|
|
487
|
+
# Compose: lidar_top ← world = (lidar_top ← base_link) · (base_link ← world)
|
|
488
|
+
R_lidar_from_world, t_lidar_from_world = compose_se3(
|
|
489
|
+
R_lidar_from_base, t_lidar_from_base,
|
|
490
|
+
R_base_from_world, t_base_from_world,
|
|
491
|
+
)
|
|
432
492
|
|
|
433
493
|
for ann_token in sample["anns"]:
|
|
434
494
|
ann = self.nusc.get("sample_annotation", ann_token)
|
|
@@ -437,13 +497,13 @@ class NuScenesConverter:
|
|
|
437
497
|
self.nusc.get("instance", nusc_instance_token)["category_token"]
|
|
438
498
|
]["name"]
|
|
439
499
|
|
|
440
|
-
# Global →
|
|
500
|
+
# Global → lidar_top
|
|
441
501
|
t_global = np.asarray(ann["translation"])
|
|
442
|
-
|
|
502
|
+
t_lidar = apply_se3(R_lidar_from_world, t_lidar_from_world, t_global)
|
|
443
503
|
|
|
444
504
|
R_ann_global = quat_to_rotation_matrix(ann["rotation"])
|
|
445
|
-
|
|
446
|
-
|
|
505
|
+
R_ann_lidar = R_lidar_from_world @ R_ann_global
|
|
506
|
+
q_ann_lidar = rotation_matrix_to_quat(R_ann_lidar)
|
|
447
507
|
|
|
448
508
|
attributes = [
|
|
449
509
|
self._attribute_by_token[at]["name"]
|
|
@@ -454,15 +514,15 @@ class NuScenesConverter:
|
|
|
454
514
|
int(ann["visibility_token"]) if ann["visibility_token"] else None
|
|
455
515
|
)
|
|
456
516
|
|
|
457
|
-
# Velocity: nuScenes computes globally; rotate into
|
|
517
|
+
# Velocity: nuScenes computes globally; rotate into lidar_top.
|
|
458
518
|
vel_global = self._compute_velocity(ann_token)
|
|
459
519
|
if vel_global is not None:
|
|
460
520
|
vel_vec = np.array([vel_global["vx"], vel_global["vy"], vel_global["vz"]])
|
|
461
|
-
|
|
521
|
+
vel_lidar = R_lidar_from_world @ vel_vec
|
|
462
522
|
velocity = {
|
|
463
|
-
"vx": float(
|
|
464
|
-
"vy": float(
|
|
465
|
-
"vz": float(
|
|
523
|
+
"vx": float(vel_lidar[0]),
|
|
524
|
+
"vy": float(vel_lidar[1]),
|
|
525
|
+
"vz": float(vel_lidar[2]),
|
|
466
526
|
}
|
|
467
527
|
else:
|
|
468
528
|
velocity = None
|
|
@@ -471,9 +531,9 @@ class NuScenesConverter:
|
|
|
471
531
|
"sample_token": new_sample_token,
|
|
472
532
|
"category": category,
|
|
473
533
|
"bbox_3d": {
|
|
474
|
-
"translation": translation_to_named(
|
|
534
|
+
"translation": translation_to_named(t_lidar.tolist()),
|
|
475
535
|
"size": size_wlh_to_named(ann["size"]),
|
|
476
|
-
"rotation": quat_wxyz_to_named(
|
|
536
|
+
"rotation": quat_wxyz_to_named(q_ann_lidar),
|
|
477
537
|
},
|
|
478
538
|
"num_lidar_pts": ann["num_lidar_pts"],
|
|
479
539
|
"visibility": visibility,
|
|
@@ -577,7 +637,7 @@ class NuScenesConverter:
|
|
|
577
637
|
)
|
|
578
638
|
sample_token = sample["next"]
|
|
579
639
|
|
|
580
|
-
# Build cs token mapping
|
|
640
|
+
# Build cs token mapping (same lowercase tokens as _extract_calibrated_sensors)
|
|
581
641
|
cs_token_map: dict[str, str] = {}
|
|
582
642
|
first_sample = self.nusc.get("sample", scene["first_sample_token"])
|
|
583
643
|
for channel, sd_token in first_sample["data"].items():
|
|
@@ -585,7 +645,7 @@ class NuScenesConverter:
|
|
|
585
645
|
continue
|
|
586
646
|
sd = self.nusc.get("sample_data", sd_token)
|
|
587
647
|
cs_token_map[sd["calibrated_sensor_token"]] = generate_token(
|
|
588
|
-
clip_id, f"cs:{channel}"
|
|
648
|
+
clip_id, f"cs:{_normalize_channel(channel)}"
|
|
589
649
|
)
|
|
590
650
|
|
|
591
651
|
# Collect all sample_data per channel, then build prev/next
|
|
@@ -613,20 +673,21 @@ class NuScenesConverter:
|
|
|
613
673
|
is_kf = sd["is_key_frame"]
|
|
614
674
|
ts = sd["timestamp"]
|
|
615
675
|
prefix = "samples" if is_kf else "sweeps"
|
|
676
|
+
x4d_ch = _normalize_channel(ch)
|
|
616
677
|
ext = ".bin" if ch == "LIDAR_TOP" else ".jpg"
|
|
617
|
-
file_path = f"{prefix}/{
|
|
678
|
+
file_path = f"{prefix}/{x4d_ch}/{ts}{ext}"
|
|
618
679
|
|
|
619
680
|
per_channel[channel].append(
|
|
620
681
|
{
|
|
621
682
|
"sample_data_token": generate_token(
|
|
622
|
-
clip_id, f"{
|
|
683
|
+
clip_id, f"{x4d_ch}:{ts}"
|
|
623
684
|
),
|
|
624
685
|
"sample_token": new_sample_token,
|
|
625
686
|
"ego_pose_token": ep_old_to_new[sd["ego_pose_token"]],
|
|
626
687
|
"calibrated_sensor_token": cs_token_map[
|
|
627
688
|
sd["calibrated_sensor_token"]
|
|
628
689
|
],
|
|
629
|
-
"channel":
|
|
690
|
+
"channel": x4d_ch,
|
|
630
691
|
"file_path": file_path,
|
|
631
692
|
"timestamp_us": ts,
|
|
632
693
|
"is_keyframe": is_kf,
|
|
@@ -154,6 +154,7 @@ class ClipLoader:
|
|
|
154
154
|
num_sweeps=d["sweep_count"],
|
|
155
155
|
sensors=sensors,
|
|
156
156
|
annotation_frame_id=d.get("annotation_frame_id"),
|
|
157
|
+
ego_pose_frame_id=d.get("ego_pose_frame_id"),
|
|
157
158
|
)
|
|
158
159
|
|
|
159
160
|
def _load_calibrated_sensors(self) -> list[CalibratedSensor]:
|
|
@@ -166,6 +167,7 @@ class ClipLoader:
|
|
|
166
167
|
result.append(CalibratedSensor(
|
|
167
168
|
token=r["calibrated_sensor_token"],
|
|
168
169
|
channel=r["channel"],
|
|
170
|
+
parent_frame=r.get("parent_frame"),
|
|
169
171
|
modality="camera" if intr is not None else "lidar",
|
|
170
172
|
translation=[t["x"], t["y"], t["z"]],
|
|
171
173
|
rotation=[q["qx"], q["qy"], q["qz"], q["qw"]],
|
|
@@ -293,6 +295,18 @@ class ClipLoader:
|
|
|
293
295
|
return self._meta.annotation_frame_id
|
|
294
296
|
return "base_link"
|
|
295
297
|
|
|
298
|
+
@property
|
|
299
|
+
def ego_pose_frame_id(self) -> str:
|
|
300
|
+
"""The frame_id whose pose ``ego_pose.json`` expresses in clip-local world.
|
|
301
|
+
|
|
302
|
+
Returns ``meta.ego_pose_frame_id`` if explicitly set (v0.7+). Pre-v0.7
|
|
303
|
+
clips fall back to ``annotation_frame_id`` — every X-4D and nuScenes
|
|
304
|
+
clip pose is anchored to the same frame as its annotations.
|
|
305
|
+
"""
|
|
306
|
+
if self._meta.ego_pose_frame_id is not None:
|
|
307
|
+
return self._meta.ego_pose_frame_id
|
|
308
|
+
return self.annotation_frame_id
|
|
309
|
+
|
|
296
310
|
def frame_id_for_channel(self, channel: str) -> str:
|
|
297
311
|
"""Resolve the ROS frame_id of a sensor channel.
|
|
298
312
|
|
|
@@ -137,6 +137,9 @@ class ClipMeta:
|
|
|
137
137
|
Required by v0.6 schema. ``None`` on older clips (callers can
|
|
138
138
|
consult ``ClipLoader.annotation_frame_id`` for a back-compat
|
|
139
139
|
fallback that infers ``"base_link"`` for v0.5 clips).
|
|
140
|
+
ego_pose_frame_id: The ROS frame_id whose pose ``ego_pose.json``
|
|
141
|
+
expresses in clip-local world. Required by v0.7 schema. ``None``
|
|
142
|
+
on older clips (loader falls back to ``annotation_frame_id``).
|
|
140
143
|
"""
|
|
141
144
|
|
|
142
145
|
clip_id: str
|
|
@@ -149,18 +152,22 @@ class ClipMeta:
|
|
|
149
152
|
num_sweeps: int
|
|
150
153
|
sensors: dict[str, SensorInfo]
|
|
151
154
|
annotation_frame_id: str | None = None
|
|
155
|
+
ego_pose_frame_id: str | None = None
|
|
152
156
|
|
|
153
157
|
|
|
154
158
|
@dataclass(frozen=True)
|
|
155
159
|
class CalibratedSensor:
|
|
156
|
-
"""Static sensor extrinsic calibration
|
|
160
|
+
"""Static sensor extrinsic calibration.
|
|
157
161
|
|
|
158
162
|
Attributes:
|
|
159
163
|
token: Unique token for this calibrated sensor record.
|
|
160
164
|
channel: Sensor channel name — equals the ROS frame_id of the sensor
|
|
161
165
|
(e.g. "lidar_top", "cam_front_left").
|
|
166
|
+
parent_frame: ROS frame_id of the parent in the calibration tree.
|
|
167
|
+
v0.8 self-reference root: ``parent_frame == channel`` marks the root.
|
|
168
|
+
Pre-v0.8 clips: parent is typically "base_link" (implicit root).
|
|
162
169
|
modality: "lidar" or "camera".
|
|
163
|
-
translation: (3,) float64 array — sensor position in
|
|
170
|
+
translation: (3,) float64 array — sensor position in parent frame [x, y, z] meters.
|
|
164
171
|
rotation: (4,) float64 array — sensor orientation as quaternion [qx, qy, qz, qw].
|
|
165
172
|
intrinsics: Full camera intrinsics dict (camera_model, fx, fy, cx, cy,
|
|
166
173
|
image_width, image_height) or None for LiDAR.
|
|
@@ -174,6 +181,7 @@ class CalibratedSensor:
|
|
|
174
181
|
rotation: np.ndarray
|
|
175
182
|
intrinsics: dict | None
|
|
176
183
|
distortion: dict | None
|
|
184
|
+
parent_frame: str | None = None
|
|
177
185
|
|
|
178
186
|
def __post_init__(self) -> None:
|
|
179
187
|
object.__setattr__(self, "translation", np.asarray(self.translation, dtype=np.float64))
|
|
@@ -200,6 +200,39 @@ class TransformChain:
|
|
|
200
200
|
for ch in self._cs_by_channel:
|
|
201
201
|
self._channel_by_frame_id.setdefault(ch, ch)
|
|
202
202
|
|
|
203
|
+
# v0.8: cached calibration tree root, derived from the self-reference
|
|
204
|
+
# record (parent_frame == channel). Older clips fall back to topology
|
|
205
|
+
# inference (root = parent_frame value not present as any channel).
|
|
206
|
+
self._root_frame_id: str | None = self._infer_root_frame_id()
|
|
207
|
+
|
|
208
|
+
def _infer_root_frame_id(self) -> str | None:
|
|
209
|
+
"""Return the calibration tree root frame_id.
|
|
210
|
+
|
|
211
|
+
v0.8+ clips have an explicit self-reference record; pre-v0.8 clips
|
|
212
|
+
fall back to topology inference (the unique parent_frame that doesn't
|
|
213
|
+
appear as any record's channel).
|
|
214
|
+
"""
|
|
215
|
+
# Self-reference (v0.8+).
|
|
216
|
+
for cs in self._loader.calibrated_sensors:
|
|
217
|
+
parent = getattr(cs, "parent_frame", None)
|
|
218
|
+
if parent == cs.channel:
|
|
219
|
+
return cs.channel
|
|
220
|
+
# Topology fallback (v0.7 and earlier).
|
|
221
|
+
channel_set = set(self._cs_by_channel.keys())
|
|
222
|
+
parent_set = {
|
|
223
|
+
getattr(cs, "parent_frame", None)
|
|
224
|
+
for cs in self._loader.calibrated_sensors
|
|
225
|
+
} - {None}
|
|
226
|
+
roots = parent_set - channel_set
|
|
227
|
+
if len(roots) == 1:
|
|
228
|
+
return next(iter(roots))
|
|
229
|
+
return None # Caller may still default to "base_link" via legacy path.
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def root_frame_id(self) -> str | None:
|
|
233
|
+
"""ROS frame_id of the calibration tree root (or None if undetermined)."""
|
|
234
|
+
return self._root_frame_id
|
|
235
|
+
|
|
203
236
|
def get_ego_from_sensor(self, channel: str) -> Transform:
|
|
204
237
|
"""Get the static sensor-to-ego transform for a channel.
|
|
205
238
|
|
|
@@ -214,32 +247,76 @@ class TransformChain:
|
|
|
214
247
|
cs = self._cs_by_channel[channel]
|
|
215
248
|
return Transform.from_quat(cs.translation, cs.rotation)
|
|
216
249
|
|
|
217
|
-
def
|
|
218
|
-
"""Get the static frame_id
|
|
250
|
+
def get_root_from_frame_id(self, frame_id: str) -> Transform:
|
|
251
|
+
"""Get the static transform from ``frame_id`` to the calibration tree root.
|
|
252
|
+
|
|
253
|
+
v0.8 unified walk: traverses parent links until a self-reference record
|
|
254
|
+
terminates (root). Pre-v0.8 clips with no self-reference record fall
|
|
255
|
+
back to "stop when parent == root_frame_id" using the inferred root.
|
|
219
256
|
|
|
220
|
-
|
|
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.
|
|
257
|
+
For pure single-node trees (frame_id IS the root), returns identity.
|
|
224
258
|
|
|
225
259
|
Args:
|
|
226
|
-
frame_id: ROS frame_id (e.g. ``"
|
|
260
|
+
frame_id: ROS frame_id (e.g. ``"lidar_top"``, ``"cam_front"``).
|
|
227
261
|
|
|
228
262
|
Returns:
|
|
229
|
-
Transform from the named frame to the
|
|
263
|
+
Transform from the named frame to the calibration tree root.
|
|
230
264
|
|
|
231
265
|
Raises:
|
|
232
|
-
KeyError: if
|
|
266
|
+
KeyError: if ``frame_id`` is not in the calibration tree.
|
|
233
267
|
"""
|
|
234
|
-
|
|
235
|
-
return Transform.identity()
|
|
268
|
+
# Resolve frame_id to its calibration record (or root marker).
|
|
236
269
|
ch = self._channel_by_frame_id.get(frame_id)
|
|
237
270
|
if ch is None:
|
|
271
|
+
# Pre-v0.8 fallback: root has no record; identity if frame_id is
|
|
272
|
+
# the inferred root.
|
|
273
|
+
if frame_id == self._root_frame_id:
|
|
274
|
+
return Transform.identity()
|
|
275
|
+
# Legacy hardcoded fallback for very old clips with no calibration tree.
|
|
276
|
+
if frame_id == "base_link":
|
|
277
|
+
return Transform.identity()
|
|
238
278
|
raise KeyError(
|
|
239
279
|
f"frame_id {frame_id!r} not found in calibration tree "
|
|
240
280
|
f"(known: {sorted(self._channel_by_frame_id)})"
|
|
241
281
|
)
|
|
242
|
-
|
|
282
|
+
|
|
283
|
+
T = Transform.identity()
|
|
284
|
+
cur = ch
|
|
285
|
+
seen: set[str] = set()
|
|
286
|
+
while True:
|
|
287
|
+
if cur in seen:
|
|
288
|
+
raise ValueError(
|
|
289
|
+
f"calibration tree has a cycle at {cur!r}: {seen}"
|
|
290
|
+
)
|
|
291
|
+
seen.add(cur)
|
|
292
|
+
cs = self._cs_by_channel.get(cur)
|
|
293
|
+
if cs is None:
|
|
294
|
+
# Pre-v0.8: this frame is the root (no record).
|
|
295
|
+
return T
|
|
296
|
+
parent = getattr(cs, "parent_frame", None)
|
|
297
|
+
if parent == cur:
|
|
298
|
+
# v0.8 self-reference: reached root, identity transform on this hop.
|
|
299
|
+
return T
|
|
300
|
+
# Compose: T_root_from_cur = T_root_from_parent ∘ T_parent_from_cur
|
|
301
|
+
step = Transform.from_quat(cs.translation, cs.rotation)
|
|
302
|
+
T = step @ T
|
|
303
|
+
if parent is None:
|
|
304
|
+
# Pre-v0.8 with explicit None parent — also a root marker.
|
|
305
|
+
return T
|
|
306
|
+
cur = self._channel_by_frame_id.get(parent, parent)
|
|
307
|
+
|
|
308
|
+
# Backward-compat alias (v0.7 callers): "ego" is the v0.7 name for what
|
|
309
|
+
# v0.8 calls "root". For X-4D platform clips they're identical (both are
|
|
310
|
+
# "base_link"); for nuScenes-rooted clips they diverge.
|
|
311
|
+
def get_ego_from_frame_id(self, frame_id: str) -> Transform:
|
|
312
|
+
"""Deprecated alias for ``get_root_from_frame_id``.
|
|
313
|
+
|
|
314
|
+
v0.7 called the calibration tree root "ego" because for X-4D platform
|
|
315
|
+
clips root == base_link == ego. v0.8 generalizes: root is whatever
|
|
316
|
+
each clip's calibration tree declares (via self-reference record),
|
|
317
|
+
not necessarily base_link / ego_vehicle.
|
|
318
|
+
"""
|
|
319
|
+
return self.get_root_from_frame_id(frame_id)
|
|
243
320
|
|
|
244
321
|
def get_world_from_ego(self, ego_pose_token: str) -> Transform:
|
|
245
322
|
"""Get the ego-to-world transform for a specific ego pose.
|
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import json
|
|
5
|
+
import re
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
|
|
7
8
|
import numpy as np
|
|
8
9
|
|
|
9
10
|
from x4d_devkit.validation.report import ValidationReport, ValidationIssue
|
|
10
11
|
|
|
12
|
+
CHANNEL_FRAME_ID_REGEX = re.compile(r"^[a-z][a-z0-9_]{0,63}$")
|
|
13
|
+
|
|
11
14
|
REQUIRED_JSON_FILES = [
|
|
12
15
|
"meta.json",
|
|
13
16
|
"calibrated_sensor.json",
|
|
@@ -101,56 +104,192 @@ def validate_clip(clip_dir: str | Path) -> ValidationReport:
|
|
|
101
104
|
if "meta.json" in jsons:
|
|
102
105
|
clip_id = jsons["meta.json"].get("clip_id", clip_id)
|
|
103
106
|
|
|
104
|
-
# Schema v0.6
|
|
107
|
+
# Schema v0.6+: explicit annotation_frame_id + per-sensor frame_id, both
|
|
105
108
|
# resolving against calibrated_sensor.json.
|
|
109
|
+
# v0.7 additions: explicit ego_pose_frame_id, lowercase regex on every
|
|
110
|
+
# channel/frame_id, and `fusion.frame` removed.
|
|
111
|
+
# v0.8 additions: self-reference root in calibrated_sensor.json,
|
|
112
|
+
# annotation_frame_id must equal some sensor's frame_id (label-source colocation).
|
|
106
113
|
if "meta.json" in jsons:
|
|
107
114
|
meta = jsons["meta.json"]
|
|
108
115
|
sv = str(meta.get("schema_version", ""))
|
|
109
|
-
|
|
116
|
+
is_v06_plus = sv in {"0.6", "0.7", "0.8"}
|
|
117
|
+
is_v07_plus = sv in {"0.7", "0.8"}
|
|
118
|
+
is_v07 = sv == "0.7"
|
|
119
|
+
is_v08 = sv == "0.8"
|
|
120
|
+
if is_v06_plus:
|
|
110
121
|
cs_records = jsons.get("calibrated_sensor.json", []) or []
|
|
111
122
|
cs_channels = {r.get("channel") for r in cs_records if r.get("channel")}
|
|
112
123
|
calib_frame_ids = set(cs_channels) | {"base_link"}
|
|
113
124
|
sensors = (meta.get("sensors") or {})
|
|
114
125
|
sensor_frame_ids: set[str] = set()
|
|
115
126
|
for ch, info in sensors.items():
|
|
127
|
+
if is_v07_plus and not CHANNEL_FRAME_ID_REGEX.match(str(ch)):
|
|
128
|
+
issues.append(
|
|
129
|
+
ValidationIssue(
|
|
130
|
+
"channel_regex",
|
|
131
|
+
"error",
|
|
132
|
+
f"channel {ch!r} violates v0.7+ ^[a-z][a-z0-9_]{{0,63}}$",
|
|
133
|
+
)
|
|
134
|
+
)
|
|
116
135
|
fid = (info or {}).get("frame_id") if isinstance(info, dict) else None
|
|
117
136
|
if not fid:
|
|
118
137
|
issues.append(
|
|
119
138
|
ValidationIssue(
|
|
120
139
|
"sensor_frame_id",
|
|
121
140
|
"error",
|
|
122
|
-
f"sensors[{ch!r}].frame_id is required in schema
|
|
141
|
+
f"sensors[{ch!r}].frame_id is required in schema {sv}",
|
|
123
142
|
)
|
|
124
143
|
)
|
|
125
144
|
continue
|
|
126
|
-
|
|
127
|
-
if ch not in cs_channels:
|
|
145
|
+
if is_v07_plus and not CHANNEL_FRAME_ID_REGEX.match(str(fid)):
|
|
128
146
|
issues.append(
|
|
129
147
|
ValidationIssue(
|
|
130
|
-
"
|
|
148
|
+
"frame_id_regex",
|
|
131
149
|
"error",
|
|
132
|
-
f"sensors[{ch!r}]
|
|
150
|
+
f"sensors[{ch!r}].frame_id={fid!r} violates v0.7+ regex",
|
|
133
151
|
)
|
|
134
152
|
)
|
|
153
|
+
if is_v07_plus and isinstance(info.get("fusion"), dict) and "frame" in info["fusion"]:
|
|
154
|
+
issues.append(
|
|
155
|
+
ValidationIssue(
|
|
156
|
+
"fusion_frame_removed",
|
|
157
|
+
"error",
|
|
158
|
+
f"sensors[{ch!r}].fusion.frame is removed in v0.7+ — "
|
|
159
|
+
"use sensors[ch].frame_id only",
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
sensor_frame_ids.add(fid)
|
|
163
|
+
# v0.8: lookup by frame_id (channel and frame_id are decoupled —
|
|
164
|
+
# producer-private channel name != ROS frame_id). Pre-v0.8: lookup
|
|
165
|
+
# by channel (legacy "channel ≡ frame_id" assumption).
|
|
166
|
+
if is_v08:
|
|
167
|
+
if fid not in cs_channels:
|
|
168
|
+
issues.append(
|
|
169
|
+
ValidationIssue(
|
|
170
|
+
"sensor_frame_id",
|
|
171
|
+
"error",
|
|
172
|
+
f"sensors[{ch!r}].frame_id={fid!r} not in calibration "
|
|
173
|
+
f"tree (known channels: {sorted(cs_channels)})",
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
else:
|
|
177
|
+
if ch not in cs_channels:
|
|
178
|
+
issues.append(
|
|
179
|
+
ValidationIssue(
|
|
180
|
+
"sensor_frame_id",
|
|
181
|
+
"error",
|
|
182
|
+
f"sensors[{ch!r}] has no matching calibrated_sensor record",
|
|
183
|
+
)
|
|
184
|
+
)
|
|
135
185
|
calib_frame_ids |= sensor_frame_ids
|
|
186
|
+
|
|
187
|
+
def _check_frame_id(field: str, value):
|
|
188
|
+
if not value:
|
|
189
|
+
issues.append(
|
|
190
|
+
ValidationIssue(
|
|
191
|
+
field, "error",
|
|
192
|
+
f"meta.{field} is required in schema {sv}",
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
return
|
|
196
|
+
if is_v07_plus and not CHANNEL_FRAME_ID_REGEX.match(str(value)):
|
|
197
|
+
issues.append(
|
|
198
|
+
ValidationIssue(
|
|
199
|
+
f"{field}_regex", "error",
|
|
200
|
+
f"meta.{field}={value!r} violates v0.7 regex",
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
if value not in calib_frame_ids:
|
|
204
|
+
issues.append(
|
|
205
|
+
ValidationIssue(
|
|
206
|
+
field, "error",
|
|
207
|
+
f"{field}={value!r} does not resolve in calibration tree "
|
|
208
|
+
f"(known: {sorted(calib_frame_ids)})",
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
|
|
136
212
|
ann_fid = meta.get("annotation_frame_id")
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
213
|
+
_check_frame_id("annotation_frame_id", ann_fid)
|
|
214
|
+
if is_v07:
|
|
215
|
+
_check_frame_id("ego_pose_frame_id", meta.get("ego_pose_frame_id"))
|
|
216
|
+
|
|
217
|
+
for r in cs_records:
|
|
218
|
+
ch = r.get("channel")
|
|
219
|
+
if is_v07_plus and ch and not CHANNEL_FRAME_ID_REGEX.match(str(ch)):
|
|
220
|
+
issues.append(
|
|
221
|
+
ValidationIssue(
|
|
222
|
+
"calibrated_sensor_channel_regex",
|
|
223
|
+
"error",
|
|
224
|
+
f"calibrated_sensor channel {ch!r} violates v0.7+ regex",
|
|
225
|
+
)
|
|
143
226
|
)
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
227
|
+
|
|
228
|
+
# v0.8 I1: exactly one self-reference root record
|
|
229
|
+
if is_v08:
|
|
230
|
+
self_refs = [
|
|
231
|
+
r for r in cs_records
|
|
232
|
+
if r.get("channel") and r.get("parent_frame") == r.get("channel")
|
|
233
|
+
]
|
|
234
|
+
if len(self_refs) == 0:
|
|
235
|
+
issues.append(
|
|
236
|
+
ValidationIssue(
|
|
237
|
+
"self_reference_root_missing",
|
|
238
|
+
"error",
|
|
239
|
+
"v0.8 requires exactly 1 calibrated_sensor record with "
|
|
240
|
+
"parent_frame == channel (self-reference root); found 0",
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
elif len(self_refs) > 1:
|
|
244
|
+
issues.append(
|
|
245
|
+
ValidationIssue(
|
|
246
|
+
"self_reference_root_multiple",
|
|
247
|
+
"error",
|
|
248
|
+
f"v0.8 requires exactly 1 self-reference root; found "
|
|
249
|
+
f"{len(self_refs)}: {[r['channel'] for r in self_refs]}",
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
else:
|
|
253
|
+
# I1 cont: root record must be identity transform.
|
|
254
|
+
root = self_refs[0]
|
|
255
|
+
t = root.get("translation") or {}
|
|
256
|
+
r = root.get("rotation") or {}
|
|
257
|
+
is_identity_t = all(abs(float(t.get(k, 0))) < 1e-9 for k in "xyz")
|
|
258
|
+
is_identity_r = (
|
|
259
|
+
abs(float(r.get("qx", 0))) < 1e-9
|
|
260
|
+
and abs(float(r.get("qy", 0))) < 1e-9
|
|
261
|
+
and abs(float(r.get("qz", 0))) < 1e-9
|
|
262
|
+
and abs(float(r.get("qw", 1)) - 1.0) < 1e-9
|
|
152
263
|
)
|
|
264
|
+
if not (is_identity_t and is_identity_r):
|
|
265
|
+
issues.append(
|
|
266
|
+
ValidationIssue(
|
|
267
|
+
"self_reference_root_not_identity",
|
|
268
|
+
"error",
|
|
269
|
+
f"v0.8 self-reference root {root['channel']!r} must be "
|
|
270
|
+
"identity transform (translation=0, rotation=identity)",
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
# v0.8 I2: annotation_frame_id must equal some sensor's frame_id
|
|
275
|
+
# (label-source colocation)
|
|
276
|
+
if is_v08 and ann_fid:
|
|
277
|
+
colocated = any(
|
|
278
|
+
info.get("frame_id") == ann_fid
|
|
279
|
+
and info.get("modality") in {"lidar"}
|
|
280
|
+
for info in sensors.values()
|
|
281
|
+
if isinstance(info, dict)
|
|
153
282
|
)
|
|
283
|
+
if not colocated:
|
|
284
|
+
issues.append(
|
|
285
|
+
ValidationIssue(
|
|
286
|
+
"label_source_colocation",
|
|
287
|
+
"error",
|
|
288
|
+
f"v0.8 requires annotation_frame_id={ann_fid!r} to equal "
|
|
289
|
+
"some sensor's frame_id (with modality lidar) — annotations "
|
|
290
|
+
"must be colocated with their source data",
|
|
291
|
+
)
|
|
292
|
+
)
|
|
154
293
|
|
|
155
294
|
# If we can't parse essential files, return early
|
|
156
295
|
if "sample.json" not in jsons or "sample_data.json" not in jsons:
|
|
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
|
|
File without changes
|
|
File without changes
|