x4d-devkit 0.3.0__tar.gz → 0.5.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.
Files changed (46) hide show
  1. {x4d_devkit-0.3.0/x4d_devkit.egg-info → x4d_devkit-0.5.0}/PKG-INFO +1 -1
  2. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/pyproject.toml +1 -1
  3. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/tests/test_coordinate_frames.py +37 -32
  4. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/tests/test_models.py +8 -4
  5. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/tests/test_nuscenes_converter.py +8 -7
  6. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/tests/test_validation.py +3 -3
  7. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/converters/nuscenes.py +65 -26
  8. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/core/loader.py +20 -14
  9. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/core/models.py +19 -11
  10. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/eval/detection.py +9 -16
  11. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/validation/validator.py +13 -0
  12. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0/x4d_devkit.egg-info}/PKG-INFO +1 -1
  13. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/LICENSE +0 -0
  14. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/README.md +0 -0
  15. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/setup.cfg +0 -0
  16. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/tests/test_detection_eval.py +0 -0
  17. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/tests/test_fusion.py +0 -0
  18. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/tests/test_loader.py +0 -0
  19. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/tests/test_matching.py +0 -0
  20. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/tests/test_metrics.py +0 -0
  21. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/tests/test_token.py +0 -0
  22. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/tests/test_transform.py +0 -0
  23. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/tests/test_transform_chain.py +0 -0
  24. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/__init__.py +0 -0
  25. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/cli.py +0 -0
  26. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/client/__init__.py +0 -0
  27. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/client/checkpoints.py +0 -0
  28. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/client/client.py +0 -0
  29. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/client/clips.py +0 -0
  30. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/client/projects.py +0 -0
  31. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/client/test_sets.py +0 -0
  32. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/converters/__init__.py +0 -0
  33. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/core/__init__.py +0 -0
  34. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/core/token.py +0 -0
  35. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/core/transform.py +0 -0
  36. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/eval/__init__.py +0 -0
  37. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/eval/matching.py +0 -0
  38. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/eval/metrics.py +0 -0
  39. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/fusion.py +0 -0
  40. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/validation/__init__.py +0 -0
  41. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit/validation/report.py +0 -0
  42. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit.egg-info/SOURCES.txt +0 -0
  43. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit.egg-info/dependency_links.txt +0 -0
  44. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit.egg-info/entry_points.txt +0 -0
  45. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/x4d_devkit.egg-info/requires.txt +0 -0
  46. {x4d_devkit-0.3.0 → x4d_devkit-0.5.0}/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.3.0
3
+ Version: 0.5.0
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.3.0"
7
+ version = "0.5.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"
@@ -56,14 +56,18 @@ def _make_synthetic_clip(tmp_path: Path) -> Path:
56
56
  "channel": "LIDAR_TOP",
57
57
  "translation": {"x": 0.0, "y": 0.0, "z": 1.0},
58
58
  "rotation": {"qx": 0.0, "qy": 0.0, "qz": 0.0, "qw": 1.0},
59
- "camera_intrinsic": None,
59
+ "intrinsics": None,
60
+ "distortion": None,
60
61
  },
61
62
  {
62
63
  "calibrated_sensor_token": "cs-cam",
63
64
  "channel": "CAM_FRONT",
64
65
  "translation": {"x": 0.5, "y": 0.0, "z": 0.8},
65
66
  "rotation": {"qx": 0.0, "qy": 0.0, "qz": 0.0, "qw": 1.0},
66
- "camera_intrinsic": {"fx": 1000, "fy": 1000, "cx": 320, "cy": 240},
67
+ "intrinsics": {"camera_model": "pinhole",
68
+ "fx": 1000, "fy": 1000, "cx": 320, "cy": 240,
69
+ "image_width": 640, "image_height": 480},
70
+ "distortion": None,
67
71
  },
68
72
  ]))
69
73
 
@@ -150,7 +154,9 @@ def _make_synthetic_clip(tmp_path: Path) -> Path:
150
154
  pts.tofile(str(lidar_dir / "frame0.bin"))
151
155
  pts.tofile(str(lidar_dir / "frame1.bin"))
152
156
 
153
- # -- annotation.json --
157
+ # -- annotation.json (v0.5: per-sample ego frame) --
158
+ # s-0 ego at origin; s-1 ego at (10, 0, 0). Same world pos (5,3,0) and (10,3,0)
159
+ # for the two frames maps to ego (5,3,0) and (0,3,0) respectively.
154
160
  (clip_dir / "annotation.json").write_text(json.dumps([
155
161
  {
156
162
  "annotation_token": "ann-0",
@@ -176,7 +182,7 @@ def _make_synthetic_clip(tmp_path: Path) -> Path:
176
182
  "instance_token": "inst-0",
177
183
  "category": "car",
178
184
  "bbox_3d": {
179
- "translation": {"x": 10.0, "y": 3.0, "z": 0.0},
185
+ "translation": {"x": 0.0, "y": 3.0, "z": 0.0},
180
186
  "size": {"length": 4.5, "width": 2.0, "height": 1.5},
181
187
  "rotation": {"qx": 0.0, "qy": 0.0, "qz": 0.0, "qw": 1.0},
182
188
  },
@@ -273,54 +279,53 @@ class TestLoadPointCloudFrames:
273
279
  # ---------------------------------------------------------------------------
274
280
 
275
281
  class TestAnnotationsForSampleFrames:
276
- def test_world_frame_default(self, loader):
277
- """Default frame should return annotations in world coordinates."""
282
+ def test_ego_frame_default(self, loader):
283
+ """Default frame should return annotations in per-sample ego coordinates (v0.5 native)."""
278
284
  anns = loader.annotations_for_sample("s-0")
279
285
  assert len(anns) == 1
280
286
  np.testing.assert_allclose(anns[0].translation, [5.0, 3.0, 0.0])
281
287
 
282
- def test_world_frame_explicit(self, loader):
283
- """Explicit frame='world' should match default."""
288
+ def test_ego_frame_explicit(self, loader):
289
+ """Explicit frame='ego' should match default."""
284
290
  anns_default = loader.annotations_for_sample("s-0")
285
- anns_world = loader.annotations_for_sample("s-0", frame="world")
286
- assert len(anns_default) == len(anns_world)
287
- np.testing.assert_allclose(anns_default[0].translation, anns_world[0].translation)
291
+ anns_ego = loader.annotations_for_sample("s-0", frame="ego")
292
+ assert len(anns_default) == len(anns_ego)
293
+ np.testing.assert_allclose(anns_default[0].translation, anns_ego[0].translation)
288
294
 
289
- def test_ego_frame_first_sample(self, loader):
290
- """frame='ego' for first sample (ego at origin): same as world."""
291
- anns = loader.annotations_for_sample("s-0", frame="ego")
292
- # ego at origin -> world-to-ego is identity
295
+ def test_world_frame_first_sample(self, loader):
296
+ """frame='world' for first sample (ego at origin): same as ego."""
297
+ anns = loader.annotations_for_sample("s-0", frame="world")
298
+ # ego at origin -> ego-to-world is identity
293
299
  np.testing.assert_allclose(anns[0].translation, [5.0, 3.0, 0.0], atol=1e-5)
294
300
 
295
- def test_ego_frame_second_sample(self, loader):
296
- """frame='ego' for second sample (ego at x=10): subtracts ego offset."""
297
- anns = loader.annotations_for_sample("s-1", frame="ego")
298
- # annotation at world (10,3,0), ego at (10,0,0) -> ego frame (0,3,0)
299
- np.testing.assert_allclose(anns[0].translation, [0.0, 3.0, 0.0], atol=1e-5)
301
+ def test_world_frame_second_sample(self, loader):
302
+ """frame='world' for second sample (ego at x=10): adds ego offset."""
303
+ anns = loader.annotations_for_sample("s-1", frame="world")
304
+ # ego frame (0,3,0), ego at (10,0,0) -> world (10,3,0)
305
+ np.testing.assert_allclose(anns[0].translation, [10.0, 3.0, 0.0], atol=1e-5)
300
306
 
301
- def test_ego_frame_velocity_rotated(self, loader):
302
- """Velocity should be rotated (not translated) to ego frame."""
307
+ def test_ego_frame_velocity_unchanged(self, loader):
308
+ """Native (ego) frame: velocity returned as stored."""
303
309
  anns = loader.annotations_for_sample("s-0", frame="ego")
304
- # ego rotation is identity, so velocity unchanged
305
310
  np.testing.assert_allclose(anns[0].velocity, [5.0, 0.0, 0.0], atol=1e-5)
306
311
 
307
- def test_ego_frame_preserves_other_fields(self, loader):
308
- """Non-pose fields should be preserved."""
309
- anns_world = loader.annotations_for_sample("s-0", frame="world")
312
+ def test_world_frame_preserves_other_fields(self, loader):
313
+ """Non-pose fields should be preserved across frame transforms."""
310
314
  anns_ego = loader.annotations_for_sample("s-0", frame="ego")
315
+ anns_world = loader.annotations_for_sample("s-0", frame="world")
311
316
  assert anns_ego[0].token == anns_world[0].token
312
317
  assert anns_ego[0].category == anns_world[0].category
313
318
  np.testing.assert_allclose(anns_ego[0].size, anns_world[0].size)
314
319
  assert anns_ego[0].num_lidar_pts == anns_world[0].num_lidar_pts
315
320
 
316
- def test_ego_frame_returns_new_objects(self, loader):
321
+ def test_world_frame_returns_new_objects(self, loader):
317
322
  """Transformed annotations should be new objects, not mutated originals."""
318
- anns_world = loader.annotations_for_sample("s-1", frame="world")
319
323
  anns_ego = loader.annotations_for_sample("s-1", frame="ego")
320
- # Originals should be unmodified
321
- np.testing.assert_allclose(anns_world[0].translation, [10.0, 3.0, 0.0])
322
- # Ego should be different
323
- assert not np.allclose(anns_ego[0].translation, anns_world[0].translation, atol=0.1)
324
+ anns_world = loader.annotations_for_sample("s-1", frame="world")
325
+ # Originals (ego, native) should be unmodified
326
+ np.testing.assert_allclose(anns_ego[0].translation, [0.0, 3.0, 0.0])
327
+ # World should be different
328
+ assert not np.allclose(anns_world[0].translation, anns_ego[0].translation, atol=0.1)
324
329
 
325
330
  def test_sensor_frame_raises(self, loader):
326
331
  with pytest.raises(ValueError, match="sensor frame"):
@@ -82,13 +82,17 @@ class TestSampleData:
82
82
  class TestCalibratedSensor:
83
83
  def test_construction(self):
84
84
  cs = CalibratedSensor(
85
- token="cs1", channel="CAM_FRONT", modality="camera",
85
+ token="cs1", channel="cam_front", modality="camera",
86
86
  translation=np.zeros(3), rotation=np.array([0, 0, 0, 1.0]),
87
- camera_intrinsic={"fx": 1266.4, "fy": 1266.4, "cx": 816.3, "cy": 491.5,
88
- "k1": 0.0, "k2": 0.0, "k3": 0.0, "p1": 0.0, "p2": 0.0},
87
+ intrinsics={"camera_model": "pinhole",
88
+ "fx": 1266.4, "fy": 1266.4, "cx": 816.3, "cy": 491.5,
89
+ "image_width": 1600, "image_height": 900},
90
+ distortion={"model": "plumb_bob",
91
+ "k1": 0.0, "k2": 0.0, "k3": 0.0, "p1": 0.0, "p2": 0.0},
89
92
  )
90
93
  assert cs.modality == "camera"
91
- assert cs.camera_intrinsic["fx"] == 1266.4
94
+ assert cs.intrinsics["fx"] == 1266.4
95
+ assert cs.distortion["model"] == "plumb_bob"
92
96
 
93
97
 
94
98
  class TestAnnotation:
@@ -108,11 +108,10 @@ class TestIntrinsicConversion:
108
108
  assert result["fy"] == pytest.approx(1266.4)
109
109
  assert result["cx"] == pytest.approx(816.3)
110
110
  assert result["cy"] == pytest.approx(491.5)
111
- assert result["k1"] == 0.0
112
- assert result["k2"] == 0.0
113
- assert result["k3"] == 0.0
114
- assert result["p1"] == 0.0
115
- assert result["p2"] == 0.0
111
+ assert result["camera_model"] == "pinhole"
112
+ # Image dimensions default to nuScenes standard 1600x900 when not given.
113
+ assert result["image_width"] == 1600
114
+ assert result["image_height"] == 900
116
115
 
117
116
  def test_empty_intrinsic(self):
118
117
  """nuScenes uses empty list for non-camera sensors."""
@@ -185,8 +184,10 @@ class TestNuScenesConverterExtraction:
185
184
  # Check named fields
186
185
  assert "x" in cam_front["translation"]
187
186
  assert "qx" in cam_front["rotation"]
188
- assert cam_front["camera_intrinsic"] is not None
189
- assert "fx" in cam_front["camera_intrinsic"]
187
+ assert cam_front["intrinsics"] is not None
188
+ assert "fx" in cam_front["intrinsics"]
189
+ assert cam_front["intrinsics"]["camera_model"] == "pinhole"
190
+ assert "child_frame" not in cam_front # dropped in v0.4
190
191
 
191
192
  def test_ego_poses_local_coordinates(self, converter):
192
193
  scene = converter.nusc.scene[0]
@@ -55,12 +55,12 @@ def valid_clip(tmp_path):
55
55
  [
56
56
  {
57
57
  "calibrated_sensor_token": "cs_lidar",
58
- "channel": "LIDAR_TOP",
58
+ "channel": "lidar_top",
59
59
  "parent_frame": "base_link",
60
- "child_frame": "lidar_top",
61
60
  "translation": {"x": 0, "y": 0, "z": 1.8},
62
61
  "rotation": {"qx": 0, "qy": 0, "qz": 0, "qw": 1},
63
- "camera_intrinsic": None,
62
+ "intrinsics": None,
63
+ "distortion": None,
64
64
  }
65
65
  ]
66
66
  )
@@ -32,20 +32,26 @@ def size_wlh_to_named(size: list[float]) -> dict[str, float]:
32
32
  return {"length": size[1], "width": size[0], "height": size[2]}
33
33
 
34
34
 
35
- def intrinsic_matrix_to_named(matrix: list[list[float]]) -> dict[str, float] | None:
36
- """Convert 3x3 intrinsic matrix to X4D named fields. Returns None for empty."""
35
+ def intrinsic_matrix_to_named(
36
+ matrix: list[list[float]],
37
+ image_width: int | None = None,
38
+ image_height: int | None = None,
39
+ ) -> dict[str, float] | None:
40
+ """Convert 3x3 intrinsic matrix to X4D v0.4 intrinsics dict. Returns None for empty.
41
+
42
+ nuScenes does not record image dimensions per-sensor; they default to the
43
+ standard 1600x900 used by the dataset when not provided.
44
+ """
37
45
  if not matrix:
38
46
  return None
39
47
  return {
48
+ "camera_model": "pinhole",
40
49
  "fx": matrix[0][0],
41
50
  "fy": matrix[1][1],
42
51
  "cx": matrix[0][2],
43
52
  "cy": matrix[1][2],
44
- "k1": 0.0,
45
- "k2": 0.0,
46
- "k3": 0.0,
47
- "p1": 0.0,
48
- "p2": 0.0,
53
+ "image_width": int(image_width) if image_width is not None else 1600,
54
+ "image_height": int(image_height) if image_height is not None else 900,
49
55
  }
50
56
 
51
57
 
@@ -238,21 +244,22 @@ class NuScenesConverter:
238
244
  sensor = self._sensor_by_token[cs["sensor_token"]]
239
245
 
240
246
  parent_frame = "base_link"
241
- child_frame = channel.lower()
247
+
248
+ intrinsics = (
249
+ intrinsic_matrix_to_named(cs["camera_intrinsic"])
250
+ if sensor["modality"] == "camera"
251
+ else None
252
+ )
242
253
 
243
254
  records.append(
244
255
  {
245
256
  "calibrated_sensor_token": generate_token(clip_id, f"cs:{channel}"),
246
257
  "channel": channel,
247
258
  "parent_frame": parent_frame,
248
- "child_frame": child_frame,
249
259
  "translation": translation_to_named(cs["translation"]),
250
260
  "rotation": quat_wxyz_to_named(cs["rotation"]),
251
- "camera_intrinsic": intrinsic_matrix_to_named(
252
- cs["camera_intrinsic"]
253
- )
254
- if sensor["modality"] == "camera"
255
- else None,
261
+ "intrinsics": intrinsics,
262
+ "distortion": None,
256
263
  }
257
264
  )
258
265
  return records
@@ -373,13 +380,36 @@ class NuScenesConverter:
373
380
  return None
374
381
  return {"vx": float(vel[0]), "vy": float(vel[1]), "vz": float(vel[2])}
375
382
 
383
+ def _get_lidar_ego_pose_for_sample(self, sample: dict) -> tuple[np.ndarray, list]:
384
+ """Return (R_global, t_global) of the LIDAR_TOP keyframe ego_pose for a sample.
385
+
386
+ v0.5 convention: the ego frame for an annotation = the ego_pose of the
387
+ sample's LiDAR keyframe sample_data.
388
+ """
389
+ lidar_sd_token = sample["data"].get("LIDAR_TOP")
390
+ if lidar_sd_token is None:
391
+ # Fallback to whichever target-channel sd has the earliest ts.
392
+ earliest_ts = None
393
+ earliest_ep = None
394
+ for channel, sd_token in sample["data"].items():
395
+ if channel not in TARGET_CHANNELS:
396
+ continue
397
+ sd = self.nusc.get("sample_data", sd_token)
398
+ ep = self.nusc.get("ego_pose", sd["ego_pose_token"])
399
+ if earliest_ts is None or ep["timestamp"] < earliest_ts:
400
+ earliest_ts = ep["timestamp"]
401
+ earliest_ep = ep
402
+ ep = earliest_ep
403
+ else:
404
+ sd = self.nusc.get("sample_data", lidar_sd_token)
405
+ ep = self.nusc.get("ego_pose", sd["ego_pose_token"])
406
+ R = quat_to_rotation_matrix(ep["rotation"])
407
+ return R, ep["translation"]
408
+
376
409
  def _extract_annotations(
377
410
  self, scene: dict, clip_id: str
378
411
  ) -> tuple[list[dict], list[dict]]:
379
- """Extract annotations and instances for the scene, in local coords."""
380
- R_first, t_first = self._get_first_ego_pose_transform(scene)
381
- R_inv, t_inv = invert_se3(R_first, t_first)
382
-
412
+ """Extract annotations and instances for the scene, in **per-sample ego frame** (v0.5)."""
383
413
  annotations: list[dict] = []
384
414
  instance_anns: dict[str, list[dict]] = {}
385
415
 
@@ -396,6 +426,10 @@ class NuScenesConverter:
396
426
  sample = self.nusc.get("sample", sample_token)
397
427
  new_sample_token = sample_token_map[sample_token]
398
428
 
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)
432
+
399
433
  for ann_token in sample["anns"]:
400
434
  ann = self.nusc.get("sample_annotation", ann_token)
401
435
  nusc_instance_token = ann["instance_token"]
@@ -403,12 +437,13 @@ class NuScenesConverter:
403
437
  self.nusc.get("instance", nusc_instance_token)["category_token"]
404
438
  ]["name"]
405
439
 
440
+ # Global → ego (this sample's ego)
406
441
  t_global = np.asarray(ann["translation"])
407
- t_local = apply_se3(R_inv, t_inv, t_global)
442
+ t_ego = apply_se3(R_inv, t_inv, t_global)
408
443
 
409
444
  R_ann_global = quat_to_rotation_matrix(ann["rotation"])
410
- R_ann_local = R_inv @ R_ann_global
411
- q_ann_local = rotation_matrix_to_quat(R_ann_local)
445
+ R_ann_ego = R_inv @ R_ann_global
446
+ q_ann_ego = rotation_matrix_to_quat(R_ann_ego)
412
447
 
413
448
  attributes = [
414
449
  self._attribute_by_token[at]["name"]
@@ -419,12 +454,16 @@ class NuScenesConverter:
419
454
  int(ann["visibility_token"]) if ann["visibility_token"] else None
420
455
  )
421
456
 
422
- # Compute velocity in global coords, then rotate to local
457
+ # Velocity: nuScenes computes globally; rotate into this sample's ego.
423
458
  vel_global = self._compute_velocity(ann_token)
424
459
  if vel_global is not None:
425
460
  vel_vec = np.array([vel_global["vx"], vel_global["vy"], vel_global["vz"]])
426
- vel_local = R_inv @ vel_vec
427
- velocity = {"vx": float(vel_local[0]), "vy": float(vel_local[1]), "vz": float(vel_local[2])}
461
+ vel_ego = R_inv @ vel_vec
462
+ velocity = {
463
+ "vx": float(vel_ego[0]),
464
+ "vy": float(vel_ego[1]),
465
+ "vz": float(vel_ego[2]),
466
+ }
428
467
  else:
429
468
  velocity = None
430
469
 
@@ -432,9 +471,9 @@ class NuScenesConverter:
432
471
  "sample_token": new_sample_token,
433
472
  "category": category,
434
473
  "bbox_3d": {
435
- "translation": translation_to_named(t_local.tolist()),
474
+ "translation": translation_to_named(t_ego.tolist()),
436
475
  "size": size_wlh_to_named(ann["size"]),
437
- "rotation": quat_wxyz_to_named(q_ann_local),
476
+ "rotation": quat_wxyz_to_named(q_ann_ego),
438
477
  },
439
478
  "num_lidar_pts": ann["num_lidar_pts"],
440
479
  "visibility": visibility,
@@ -115,13 +115,15 @@ class ClipLoader:
115
115
  for r in records:
116
116
  t = r["translation"]
117
117
  q = r["rotation"]
118
+ intr = r.get("intrinsics")
118
119
  result.append(CalibratedSensor(
119
120
  token=r["calibrated_sensor_token"],
120
121
  channel=r["channel"],
121
- modality="lidar" if r["camera_intrinsic"] is None else "camera",
122
+ modality="camera" if intr is not None else "lidar",
122
123
  translation=[t["x"], t["y"], t["z"]],
123
124
  rotation=[q["qx"], q["qy"], q["qz"], q["qw"]],
124
- camera_intrinsic=r["camera_intrinsic"],
125
+ intrinsics=intr,
126
+ distortion=r.get("distortion"),
125
127
  ))
126
128
  return result
127
129
 
@@ -248,7 +250,10 @@ class ClipLoader:
248
250
 
249
251
  @property
250
252
  def annotations(self) -> list[Annotation]:
251
- """All annotations in world frame."""
253
+ """All annotations in their **native frame** (ego since v0.5; world for v0.4 clips).
254
+
255
+ Use ``annotations_for_sample(token, frame=...)`` to request a specific frame.
256
+ """
252
257
  return self._annotations
253
258
 
254
259
  @property
@@ -295,16 +300,17 @@ class ClipLoader:
295
300
  def annotations_for_sample(
296
301
  self,
297
302
  sample_token: str,
298
- frame: FrameType = "world",
303
+ frame: FrameType = "ego",
299
304
  ) -> list[Annotation]:
300
305
  """All annotations for a given sample.
301
306
 
302
307
  Args:
303
308
  sample_token: The sample token.
304
309
  frame: Target coordinate frame.
305
- - "world" (default): native annotation frame, no transform.
306
- - "ego": transform from world to ego frame using the ego_pose
307
- of the LiDAR sample_data for this sample (or first sample_data
310
+ - "ego" (default, v0.5 native): no transform annotations are
311
+ stored in the per-sample ego frame.
312
+ - "world": transform from ego to world using the ego_pose of
313
+ the LiDAR sample_data for this sample (or first sample_data
308
314
  if no LiDAR).
309
315
 
310
316
  Returns:
@@ -316,12 +322,12 @@ class ClipLoader:
316
322
  raise ValueError("Annotations cannot be transformed to sensor frame")
317
323
 
318
324
  anns = self._ann_by_sample.get(sample_token, [])
319
- if frame == "world" or not anns:
325
+ if frame == "ego" or not anns:
320
326
  return anns
321
327
 
322
- # frame == "ego": need ego_pose for this sample
323
- ego_from_world = self._get_ego_from_world_for_sample(sample_token)
324
- return [self._transform_annotation(a, ego_from_world) for a in anns]
328
+ # frame == "world": transform per-sample-ego world via ego_pose.
329
+ world_from_ego = self._get_world_from_ego_for_sample(sample_token)
330
+ return [self._transform_annotation(a, world_from_ego) for a in anns]
325
331
 
326
332
  def load_point_cloud(
327
333
  self,
@@ -420,8 +426,8 @@ class ClipLoader:
420
426
  self._transform_chain = TransformChain(self)
421
427
  return self._transform_chain
422
428
 
423
- def _get_ego_from_world_for_sample(self, sample_token: str) -> Transform:
424
- """Get the inverse ego_pose transform for a sample.
429
+ def _get_world_from_ego_for_sample(self, sample_token: str) -> Transform:
430
+ """Get the world_from_ego transform for a sample.
425
431
 
426
432
  Uses the ego_pose from the LiDAR sample_data, or the first sample_data
427
433
  if no LiDAR channel exists.
@@ -437,7 +443,7 @@ class ClipLoader:
437
443
  sds[0],
438
444
  )
439
445
  chain = self._get_transform_chain()
440
- return chain.get_world_from_ego(sd.ego_pose_token).inverse
446
+ return chain.get_world_from_ego(sd.ego_pose_token)
441
447
 
442
448
  @staticmethod
443
449
  def _transform_annotation(ann: Annotation, T: Transform) -> Annotation:
@@ -53,11 +53,14 @@ class CalibratedSensor:
53
53
 
54
54
  Attributes:
55
55
  token: Unique token for this calibrated sensor record.
56
- channel: Sensor channel name (e.g. "LIDAR_TOP", "CAM_FRONT").
56
+ channel: Sensor channel name equals the ROS frame_id of the sensor
57
+ (e.g. "lidar_top", "cam_front_left").
57
58
  modality: "lidar" or "camera".
58
59
  translation: (3,) float64 array — sensor position in ego frame [x, y, z] meters.
59
60
  rotation: (4,) float64 array — sensor orientation as quaternion [qx, qy, qz, qw].
60
- camera_intrinsic: Camera intrinsic parameters (fx, fy, cx, cy), or None for LiDAR.
61
+ intrinsics: Full camera intrinsics dict (camera_model, fx, fy, cx, cy,
62
+ image_width, image_height) or None for LiDAR.
63
+ distortion: Distortion model dict (model + coefficients) or None.
61
64
  """
62
65
 
63
66
  token: str
@@ -65,7 +68,8 @@ class CalibratedSensor:
65
68
  modality: str
66
69
  translation: np.ndarray
67
70
  rotation: np.ndarray
68
- camera_intrinsic: dict[str, float] | None
71
+ intrinsics: dict | None
72
+ distortion: dict | None
69
73
 
70
74
  def __post_init__(self) -> None:
71
75
  object.__setattr__(self, "translation", np.asarray(self.translation, dtype=np.float64))
@@ -150,23 +154,27 @@ class SampleData:
150
154
 
151
155
  @dataclass(frozen=True)
152
156
  class Annotation:
153
- """A 3D bounding box annotation in world frame.
157
+ """A 3D bounding box annotation in **per-sample ego frame** (v0.5).
154
158
 
155
- Annotations are natively stored in the clip's local world frame
156
- (first ego pose origin). Use ``ClipLoader.annotations_for_sample(..., frame="ego")``
157
- to transform to ego frame.
159
+ Annotations are natively stored in the ego frame of the sample they belong
160
+ to (the ego_pose of the sample's LiDAR keyframe). Use
161
+ ``ClipLoader.annotations_for_sample(..., frame="world")`` to transform to
162
+ the clip's local world frame when needed (rare — typically only for
163
+ cross-frame trajectory visualization).
158
164
 
159
165
  Attributes:
160
166
  token: Unique annotation token.
161
167
  sample_token: Token of the sample this annotation belongs to.
162
168
  instance_token: Token of the tracked object instance.
163
169
  category: Object category string (e.g. "car", "pedestrian").
164
- translation: (3,) float64 array — box center [x, y, z] in world frame.
170
+ translation: (3,) float64 array — box center [x, y, z] in this sample's ego frame.
165
171
  size: (3,) float64 array — box dimensions [length, width, height] in meters.
166
- rotation: (4,) float64 array — box orientation as quaternion [qx, qy, qz, qw].
167
- num_lidar_pts: Number of LiDAR points inside this box.
172
+ rotation: (4,) float64 array — box orientation as quaternion [qx, qy, qz, qw],
173
+ relative to this sample's ego frame.
174
+ num_lidar_pts: Number of LiDAR points inside this box (real-counted, ≥ 0).
168
175
  visibility: Visibility level as string ("1" to "4", or "" if unknown).
169
- velocity: (3,) float64 array — velocity [vx, vy, vz] in m/s, or None.
176
+ velocity: (3,) float64 array — velocity [vx, vy, vz] in m/s, **ego frame**
177
+ (forward = +x, left = +y), or None.
170
178
  attributes: List of attribute strings (e.g. ["vehicle.moving"]).
171
179
  prev: Token of previous annotation of same instance, or "" if first.
172
180
  next: Token of next annotation of same instance, or "" if last.
@@ -223,33 +223,26 @@ def _extract_boxes(
223
223
  config: DetectionConfig,
224
224
  max_boxes: int | None = None,
225
225
  ) -> dict[str, list[dict]]:
226
- """Extract annotation boxes grouped by sample_token, with distance filtering."""
227
- ep_by_token = {ep.token: ep for ep in clip.ego_poses}
226
+ """Extract annotation boxes grouped by sample_token, with distance filtering.
227
+
228
+ v0.5: annotations are in per-sample ego frame, so distance from ego is
229
+ simply ``||ann.translation[:2]||`` — no subtraction of ego_pose required.
230
+ """
228
231
  category_map = config.category_map or {}
229
232
  class_names = config.class_names
230
233
  class_range = config.class_range
231
234
 
232
- # Map sample_token → ego position (for distance filtering)
233
- sample_ego_pos: dict[str, np.ndarray] = {}
234
- for sd in clip.sample_data:
235
- if sd.sample_token not in sample_ego_pos and sd.is_keyframe:
236
- ep = ep_by_token.get(sd.ego_pose_token)
237
- if ep is not None:
238
- sample_ego_pos[sd.sample_token] = ep.translation
239
-
240
235
  result: dict[str, list[dict]] = {}
241
236
  for sample in clip.samples:
242
- ego_pos = sample_ego_pos.get(sample.token)
243
- anns = clip.annotations_for_sample(sample.token)
237
+ anns = clip.annotations_for_sample(sample.token) # default = ego in v0.5
244
238
  boxes = []
245
239
  for ann in anns:
246
240
  det_name = map_category(ann.category, category_map, class_names)
247
241
  if det_name is None or det_name not in class_range:
248
242
  continue
249
- if ego_pos is not None:
250
- dist = float(np.linalg.norm(ann.translation[:2] - ego_pos[:2]))
251
- if dist > class_range[det_name]:
252
- continue
243
+ dist = float(np.linalg.norm(ann.translation[:2]))
244
+ if dist > class_range[det_name]:
245
+ continue
253
246
  boxes.append({
254
247
  "translation": ann.translation,
255
248
  "size": ann.size,
@@ -218,6 +218,19 @@ def validate_clip(clip_dir: str | Path) -> ValidationReport:
218
218
  )
219
219
  break
220
220
 
221
+ # num_lidar_pts must be a non-negative integer (v0.5+: real-computed,
222
+ # 0 means "no points in box", not a default placeholder).
223
+ npts = ann.get("num_lidar_pts")
224
+ if not isinstance(npts, int) or isinstance(npts, bool) or npts < 0:
225
+ issues.append(
226
+ ValidationIssue(
227
+ "annotation_num_lidar_pts",
228
+ "error",
229
+ f"Invalid num_lidar_pts={npts!r} (must be int >= 0)",
230
+ ann.get("annotation_token", ""),
231
+ )
232
+ )
233
+
221
234
  # Sample token reference
222
235
  if ann.get("sample_token") not in sample_tokens:
223
236
  issues.append(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: x4d-devkit
3
- Version: 0.3.0
3
+ Version: 0.5.0
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
File without changes