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.
Files changed (52) hide show
  1. {x4d_devkit-0.9.0/x4d_devkit.egg-info → x4d_devkit-0.11.1}/PKG-INFO +1 -1
  2. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/pyproject.toml +1 -1
  3. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/converters/nuscenes.py +89 -28
  4. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/core/loader.py +14 -0
  5. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/core/models.py +10 -2
  6. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/core/transform.py +89 -12
  7. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/validation/validator.py +160 -21
  8. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1/x4d_devkit.egg-info}/PKG-INFO +1 -1
  9. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/LICENSE +0 -0
  10. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/README.md +0 -0
  11. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/setup.cfg +0 -0
  12. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/tests/test_box.py +0 -0
  13. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/tests/test_coordinate_frames.py +0 -0
  14. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/tests/test_detection_eval.py +0 -0
  15. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/tests/test_evaluate.py +0 -0
  16. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/tests/test_fusion.py +0 -0
  17. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/tests/test_loader.py +0 -0
  18. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/tests/test_matching.py +0 -0
  19. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/tests/test_metrics.py +0 -0
  20. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/tests/test_models.py +0 -0
  21. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/tests/test_nuscenes_converter.py +0 -0
  22. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/tests/test_token.py +0 -0
  23. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/tests/test_transform.py +0 -0
  24. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/tests/test_transform_chain.py +0 -0
  25. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/tests/test_v06_api_additions.py +0 -0
  26. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/tests/test_v06_schema.py +0 -0
  27. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/tests/test_validation.py +0 -0
  28. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/__init__.py +0 -0
  29. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/cli.py +0 -0
  30. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/client/__init__.py +0 -0
  31. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/client/checkpoints.py +0 -0
  32. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/client/client.py +0 -0
  33. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/client/clips.py +0 -0
  34. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/client/projects.py +0 -0
  35. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/client/test_sets.py +0 -0
  36. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/converters/__init__.py +0 -0
  37. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/core/__init__.py +0 -0
  38. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/core/token.py +0 -0
  39. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/eval/__init__.py +0 -0
  40. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/eval/api.py +0 -0
  41. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/eval/box.py +0 -0
  42. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/eval/detection.py +0 -0
  43. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/eval/matching.py +0 -0
  44. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/eval/metrics.py +0 -0
  45. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/fusion.py +0 -0
  46. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/validation/__init__.py +0 -0
  47. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit/validation/report.py +0 -0
  48. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit.egg-info/SOURCES.txt +0 -0
  49. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit.egg-info/dependency_links.txt +0 -0
  50. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit.egg-info/entry_points.txt +0 -0
  51. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/x4d_devkit.egg-info/requires.txt +0 -0
  52. {x4d_devkit-0.9.0 → x4d_devkit-0.11.1}/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.9.0
3
+ Version: 0.11.1
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.9.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[channel] = {
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[channel] = {
198
+ sensors_dict[x4d_ch] = {
186
199
  "modality": "camera",
187
- "resolution": [sd["width"], sd["height"]],
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.1",
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:{channel}"),
257
- "channel": 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 **per-sample ego frame** (v0.5)."""
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
- # Per-sample ego_pose (LIDAR_TOP keyframe) annotation frame.
430
- R_ego_global, t_ego_global = self._get_lidar_ego_pose_for_sample(sample)
431
- R_inv, t_inv = invert_se3(R_ego_global, t_ego_global)
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 → ego (this sample's ego)
500
+ # Global → lidar_top
441
501
  t_global = np.asarray(ann["translation"])
442
- t_ego = apply_se3(R_inv, t_inv, t_global)
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
- R_ann_ego = R_inv @ R_ann_global
446
- q_ann_ego = rotation_matrix_to_quat(R_ann_ego)
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 this sample's ego.
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
- vel_ego = R_inv @ vel_vec
521
+ vel_lidar = R_lidar_from_world @ vel_vec
462
522
  velocity = {
463
- "vx": float(vel_ego[0]),
464
- "vy": float(vel_ego[1]),
465
- "vz": float(vel_ego[2]),
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(t_ego.tolist()),
534
+ "translation": translation_to_named(t_lidar.tolist()),
475
535
  "size": size_wlh_to_named(ann["size"]),
476
- "rotation": quat_wxyz_to_named(q_ann_ego),
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}/{ch}/{ts}{ext}"
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"{ch}:{ts}"
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": ch,
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 (sensor → ego transform).
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 ego frame [x, y, z] meters.
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 get_ego_from_frame_id(self, frame_id: str) -> Transform:
218
- """Get the static frame_id-to-ego transform.
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
- 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.
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. ``"LIDAR_TOP"``, ``"camera-front"``).
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 ego (base_link) frame.
263
+ Transform from the named frame to the calibration tree root.
230
264
 
231
265
  Raises:
232
- KeyError: if no sensor declares ``frame_id``.
266
+ KeyError: if ``frame_id`` is not in the calibration tree.
233
267
  """
234
- if frame_id == "base_link":
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
- return self.get_ego_from_sensor(ch)
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: explicit annotation_frame_id + per-sensor frame_id, both
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
- if sv == "0.6":
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 0.6",
141
+ f"sensors[{ch!r}].frame_id is required in schema {sv}",
123
142
  )
124
143
  )
125
144
  continue
126
- sensor_frame_ids.add(fid)
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
- "sensor_frame_id",
148
+ "frame_id_regex",
131
149
  "error",
132
- f"sensors[{ch!r}] has no matching calibrated_sensor record",
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
- 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",
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
- 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)})",
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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: x4d-devkit
3
- Version: 0.9.0
3
+ Version: 0.11.1
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
File without changes
File without changes
File without changes