x4d-devkit 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,715 @@
1
+ """NuScenes → X4D format converter.
2
+
3
+ Requires: pip install x4d-devkit[converters]
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import shutil
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import numpy as np
14
+
15
+ from x4d_devkit.core.token import generate_token
16
+
17
+ # --- Field mapping helpers ---
18
+
19
+
20
+ def quat_wxyz_to_named(q: list[float]) -> dict[str, float]:
21
+ """Convert nuScenes [w,x,y,z] quaternion to X4D {qx,qy,qz,qw}."""
22
+ return {"qx": q[1], "qy": q[2], "qz": q[3], "qw": q[0]}
23
+
24
+
25
+ def translation_to_named(t: list[float]) -> dict[str, float]:
26
+ """Convert [x,y,z] array to {x,y,z} dict."""
27
+ return {"x": t[0], "y": t[1], "z": t[2]}
28
+
29
+
30
+ def size_wlh_to_named(size: list[float]) -> dict[str, float]:
31
+ """Convert nuScenes [w,l,h] to X4D {length,width,height}."""
32
+ return {"length": size[1], "width": size[0], "height": size[2]}
33
+
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."""
37
+ if not matrix:
38
+ return None
39
+ return {
40
+ "fx": matrix[0][0],
41
+ "fy": matrix[1][1],
42
+ "cx": matrix[0][2],
43
+ "cy": matrix[1][2],
44
+ "k1": 0.0,
45
+ "k2": 0.0,
46
+ "k3": 0.0,
47
+ "p1": 0.0,
48
+ "p2": 0.0,
49
+ }
50
+
51
+
52
+ def build_clip_id(date_captured: str, vehicle: str, scene_name: str) -> str:
53
+ """Build X4D clip_id from nuScenes log metadata.
54
+
55
+ Format: {YYYYMMDD}_{vehicle}_{scene_name}_000
56
+ """
57
+ date_str = date_captured.replace("-", "")
58
+ return f"{date_str}_{vehicle}_{scene_name}_000"
59
+
60
+
61
+ # --- SE3 transform helpers (converter uses [w,x,y,z] internally) ---
62
+
63
+ from x4d_devkit.core.transform import (
64
+ quat_to_rotation_matrix as _quat_xyzw_to_matrix,
65
+ rotation_matrix_to_quat as _matrix_to_quat_xyzw,
66
+ )
67
+
68
+
69
+ def quat_to_rotation_matrix(q: list[float]) -> np.ndarray:
70
+ """Convert [w,x,y,z] quaternion to 3x3 rotation matrix.
71
+
72
+ Thin wrapper: converts nuScenes [w,x,y,z] to X4D [qx,qy,qz,qw].
73
+ """
74
+ w, x, y, z = q[0], q[1], q[2], q[3]
75
+ return _quat_xyzw_to_matrix(x, y, z, w)
76
+
77
+
78
+ def rotation_matrix_to_quat(R: np.ndarray) -> list[float]:
79
+ """Convert 3x3 rotation matrix to [w,x,y,z] quaternion (nuScenes convention)."""
80
+ qx, qy, qz, qw = _matrix_to_quat_xyzw(R)
81
+ return [qw, qx, qy, qz]
82
+
83
+
84
+ def apply_se3(
85
+ R: np.ndarray, t: list[float] | np.ndarray, point: np.ndarray
86
+ ) -> np.ndarray:
87
+ """Apply SE3 transform: p' = R @ p + t."""
88
+ return R @ point + np.asarray(t)
89
+
90
+
91
+ def invert_se3(
92
+ R: np.ndarray, t: list[float] | np.ndarray
93
+ ) -> tuple[np.ndarray, np.ndarray]:
94
+ """Invert SE3 transform. Returns (R_inv, t_inv)."""
95
+ R_inv = R.T
96
+ t_inv = -R_inv @ np.asarray(t)
97
+ return R_inv, t_inv
98
+
99
+
100
+ def compose_se3(
101
+ R1: np.ndarray,
102
+ t1: list[float] | np.ndarray,
103
+ R2: np.ndarray,
104
+ t2: list[float] | np.ndarray,
105
+ ) -> tuple[np.ndarray, np.ndarray]:
106
+ """Compose two SE3 transforms: T1 @ T2. Returns (R, t)."""
107
+ R = R1 @ R2
108
+ t = R1 @ np.asarray(t2) + np.asarray(t1)
109
+ return R, t
110
+
111
+
112
+ # --- Target sensor channels (skip radar) ---
113
+
114
+ TARGET_CHANNELS = {
115
+ "CAM_FRONT",
116
+ "CAM_FRONT_LEFT",
117
+ "CAM_FRONT_RIGHT",
118
+ "CAM_BACK",
119
+ "CAM_BACK_LEFT",
120
+ "CAM_BACK_RIGHT",
121
+ "LIDAR_TOP",
122
+ }
123
+
124
+ LIDAR_POINT_FORMAT = {
125
+ "fields": ["x", "y", "z", "intensity", "ring_index"],
126
+ "types": ["float32", "float32", "float32", "float32", "float32"],
127
+ "bytes_per_point": 20,
128
+ }
129
+
130
+
131
+ class NuScenesConverter:
132
+ """Convert nuScenes scenes to X4D clip format."""
133
+
134
+ def __init__(self, dataroot: str, version: str, output_dir: str) -> None:
135
+ from nuscenes.nuscenes import NuScenes
136
+
137
+ self.dataroot = Path(dataroot)
138
+ self.version = version
139
+ self.output_dir = Path(output_dir)
140
+ self.nusc = NuScenes(version=version, dataroot=str(dataroot), verbose=False)
141
+
142
+ # Build lookup tables
143
+ self._sensor_by_token = {s["token"]: s for s in self.nusc.sensor}
144
+ self._cs_by_token = {c["token"]: c for c in self.nusc.calibrated_sensor}
145
+ self._log_by_token = {lg["token"]: lg for lg in self.nusc.log}
146
+ self._category_by_token = {c["token"]: c for c in self.nusc.category}
147
+ self._attribute_by_token = {a["token"]: a for a in self.nusc.attribute}
148
+
149
+ def _get_channel(self, calibrated_sensor_token: str) -> str:
150
+ """Get sensor channel name from calibrated_sensor_token."""
151
+ cs = self._cs_by_token[calibrated_sensor_token]
152
+ return self._sensor_by_token[cs["sensor_token"]]["channel"]
153
+
154
+ def _extract_metadata(self, scene: dict) -> tuple[str, dict]:
155
+ """Extract clip metadata from a scene. Returns (clip_id, meta_dict)."""
156
+ log = self._log_by_token[scene["log_token"]]
157
+ clip_id = build_clip_id(
158
+ date_captured=log["date_captured"],
159
+ vehicle=log["vehicle"],
160
+ scene_name=scene["name"],
161
+ )
162
+
163
+ # Collect target sensors from first sample
164
+ sensors_dict: dict[str, dict] = {}
165
+ sample_token = scene["first_sample_token"]
166
+ first_sample = self.nusc.get("sample", sample_token)
167
+ for channel, sd_token in first_sample["data"].items():
168
+ if channel not in TARGET_CHANNELS:
169
+ continue
170
+ sd = self.nusc.get("sample_data", sd_token)
171
+ cs = self._cs_by_token[sd["calibrated_sensor_token"]]
172
+ sensor = self._sensor_by_token[cs["sensor_token"]]
173
+ if sensor["modality"] == "lidar":
174
+ sensors_dict[channel] = {
175
+ "modality": "lidar",
176
+ "point_format": LIDAR_POINT_FORMAT,
177
+ }
178
+ elif sensor["modality"] == "camera":
179
+ sensors_dict[channel] = {
180
+ "modality": "camera",
181
+ "resolution": [sd["width"], sd["height"]],
182
+ }
183
+
184
+ # Count keyframes
185
+ keyframe_count = scene["nbr_samples"]
186
+
187
+ # Count sweeps
188
+ sweep_count = 0
189
+ sample_token = scene["first_sample_token"]
190
+ while sample_token:
191
+ sample = self.nusc.get("sample", sample_token)
192
+ for channel, sd_token in sample["data"].items():
193
+ if channel not in TARGET_CHANNELS:
194
+ continue
195
+ sd = self.nusc.get("sample_data", sd_token)
196
+ cur = sd["next"]
197
+ while cur:
198
+ next_sd = self.nusc.get("sample_data", cur)
199
+ if next_sd["is_key_frame"]:
200
+ break
201
+ sweep_count += 1
202
+ cur = next_sd["next"]
203
+ sample_token = sample["next"]
204
+
205
+ # Compute duration
206
+ first_sample = self.nusc.get("sample", scene["first_sample_token"])
207
+ last_sample = self.nusc.get("sample", scene["last_sample_token"])
208
+ duration_s = (last_sample["timestamp"] - first_sample["timestamp"]) / 1e6
209
+
210
+ meta = {
211
+ "clip_id": clip_id,
212
+ "session_id": log["logfile"],
213
+ "vehicle_id": log["vehicle"],
214
+ "capture_time_utc": f"{log['date_captured']}T00:00:00Z",
215
+ "duration_s": round(duration_s, 1),
216
+ "keyframe_count": keyframe_count,
217
+ "sweep_count": sweep_count,
218
+ "sensors": sensors_dict,
219
+ "route_tag": None,
220
+ "weather": None,
221
+ "time_of_day": None,
222
+ "scene_tags": [log.get("location", "unknown")],
223
+ "schema_version": "0.1",
224
+ }
225
+ return clip_id, meta
226
+
227
+ def _extract_calibrated_sensors(
228
+ self, scene: dict, clip_id: str
229
+ ) -> list[dict]:
230
+ """Extract calibrated sensor records for target channels."""
231
+ first_sample = self.nusc.get("sample", scene["first_sample_token"])
232
+ records = []
233
+ for channel, sd_token in first_sample["data"].items():
234
+ if channel not in TARGET_CHANNELS:
235
+ continue
236
+ sd = self.nusc.get("sample_data", sd_token)
237
+ cs = self._cs_by_token[sd["calibrated_sensor_token"]]
238
+ sensor = self._sensor_by_token[cs["sensor_token"]]
239
+
240
+ parent_frame = "base_link"
241
+ child_frame = channel.lower()
242
+
243
+ records.append(
244
+ {
245
+ "calibrated_sensor_token": generate_token(clip_id, f"cs:{channel}"),
246
+ "channel": channel,
247
+ "parent_frame": parent_frame,
248
+ "child_frame": child_frame,
249
+ "translation": translation_to_named(cs["translation"]),
250
+ "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,
256
+ }
257
+ )
258
+ return records
259
+
260
+ def _get_first_ego_pose_transform(self, scene: dict) -> tuple[np.ndarray, list]:
261
+ """Get the SE3 transform of the first keyframe's ego_pose (for localizing).
262
+
263
+ Uses the ego_pose with the earliest timestamp among all target-channel
264
+ keyframe sensor-data records in the first sample, so that the earliest
265
+ keyframe ego_pose maps exactly to the local origin.
266
+ """
267
+ first_sample = self.nusc.get("sample", scene["first_sample_token"])
268
+ earliest_ts = None
269
+ earliest_ep = None
270
+ for channel, sd_token in first_sample["data"].items():
271
+ if channel not in TARGET_CHANNELS:
272
+ continue
273
+ sd = self.nusc.get("sample_data", sd_token)
274
+ ep = self.nusc.get("ego_pose", sd["ego_pose_token"])
275
+ if earliest_ts is None or ep["timestamp"] < earliest_ts:
276
+ earliest_ts = ep["timestamp"]
277
+ earliest_ep = ep
278
+ R = quat_to_rotation_matrix(earliest_ep["rotation"])
279
+ return R, earliest_ep["translation"]
280
+
281
+ def _extract_ego_poses(
282
+ self, scene: dict, clip_id: str
283
+ ) -> list[dict]:
284
+ """Extract all ego_poses for the scene (keyframe + sweep), in local coords."""
285
+ R_first, t_first = self._get_first_ego_pose_transform(scene)
286
+ R_inv, t_inv = invert_se3(R_first, t_first)
287
+
288
+ seen_ep_tokens: set[str] = set()
289
+ records: list[dict] = []
290
+
291
+ sample_token = scene["first_sample_token"]
292
+ while sample_token:
293
+ sample = self.nusc.get("sample", sample_token)
294
+ for channel, sd_token in sample["data"].items():
295
+ if channel not in TARGET_CHANNELS:
296
+ continue
297
+ cur_sd_token = sd_token
298
+ while cur_sd_token:
299
+ sd = self.nusc.get("sample_data", cur_sd_token)
300
+ ep_token = sd["ego_pose_token"]
301
+ if ep_token not in seen_ep_tokens:
302
+ seen_ep_tokens.add(ep_token)
303
+ ep = self.nusc.get("ego_pose", ep_token)
304
+ R_global = quat_to_rotation_matrix(ep["rotation"])
305
+ t_global = np.asarray(ep["translation"])
306
+
307
+ R_local, t_local = compose_se3(R_inv, t_inv, R_global, t_global)
308
+ q_local = rotation_matrix_to_quat(R_local)
309
+
310
+ records.append(
311
+ {
312
+ "ego_pose_token": generate_token(
313
+ clip_id, f"ego:{ep['timestamp']}"
314
+ ),
315
+ "timestamp_us": ep["timestamp"],
316
+ "translation": translation_to_named(t_local.tolist()),
317
+ "rotation": quat_wxyz_to_named(q_local),
318
+ "_original_token": ep_token,
319
+ "_is_keyframe": sd["is_key_frame"],
320
+ }
321
+ )
322
+
323
+ next_sd_token = sd["next"]
324
+ if next_sd_token:
325
+ next_sd = self.nusc.get("sample_data", next_sd_token)
326
+ if next_sd["is_key_frame"]:
327
+ break
328
+ cur_sd_token = next_sd_token
329
+
330
+ sample_token = sample["next"]
331
+
332
+ return records
333
+
334
+ def _compute_velocity(self, ann_token: str) -> dict[str, float] | None:
335
+ """Compute velocity using centered difference (matches nuScenes box_velocity).
336
+
337
+ Returns {"vx": ..., "vy": ..., "vz": ...} in GLOBAL coordinates, or None.
338
+ """
339
+ ann = self.nusc.get("sample_annotation", ann_token)
340
+ has_prev = ann["prev"] != ""
341
+ has_next = ann["next"] != ""
342
+
343
+ if not has_prev and not has_next:
344
+ return None
345
+
346
+ if has_prev:
347
+ first = self.nusc.get("sample_annotation", ann["prev"])
348
+ else:
349
+ first = ann
350
+
351
+ if has_next:
352
+ last = self.nusc.get("sample_annotation", ann["next"])
353
+ else:
354
+ last = ann
355
+
356
+ pos_last = np.asarray(last["translation"])
357
+ pos_first = np.asarray(first["translation"])
358
+ pos_diff = pos_last - pos_first
359
+
360
+ time_last = 1e-6 * self.nusc.get("sample", last["sample_token"])["timestamp"]
361
+ time_first = 1e-6 * self.nusc.get("sample", first["sample_token"])["timestamp"]
362
+ time_diff = time_last - time_first
363
+
364
+ max_time_diff = 1.5
365
+ if has_next and has_prev:
366
+ max_time_diff *= 2
367
+
368
+ if time_diff > max_time_diff or time_diff == 0:
369
+ return None
370
+
371
+ vel = pos_diff / time_diff
372
+ if np.any(np.isnan(vel)):
373
+ return None
374
+ return {"vx": float(vel[0]), "vy": float(vel[1]), "vz": float(vel[2])}
375
+
376
+ def _extract_annotations(
377
+ self, scene: dict, clip_id: str
378
+ ) -> 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
+
383
+ annotations: list[dict] = []
384
+ instance_anns: dict[str, list[dict]] = {}
385
+
386
+ sample_token_map: dict[str, str] = {}
387
+ sample_token = scene["first_sample_token"]
388
+ while sample_token:
389
+ sample = self.nusc.get("sample", sample_token)
390
+ new_token = generate_token(clip_id, str(sample["timestamp"]))
391
+ sample_token_map[sample_token] = new_token
392
+ sample_token = sample["next"]
393
+
394
+ sample_token = scene["first_sample_token"]
395
+ while sample_token:
396
+ sample = self.nusc.get("sample", sample_token)
397
+ new_sample_token = sample_token_map[sample_token]
398
+
399
+ for ann_token in sample["anns"]:
400
+ ann = self.nusc.get("sample_annotation", ann_token)
401
+ nusc_instance_token = ann["instance_token"]
402
+ category = self._category_by_token[
403
+ self.nusc.get("instance", nusc_instance_token)["category_token"]
404
+ ]["name"]
405
+
406
+ t_global = np.asarray(ann["translation"])
407
+ t_local = apply_se3(R_inv, t_inv, t_global)
408
+
409
+ 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)
412
+
413
+ attributes = [
414
+ self._attribute_by_token[at]["name"]
415
+ for at in ann["attribute_tokens"]
416
+ ]
417
+
418
+ visibility = (
419
+ int(ann["visibility_token"]) if ann["visibility_token"] else None
420
+ )
421
+
422
+ # Compute velocity in global coords, then rotate to local
423
+ vel_global = self._compute_velocity(ann_token)
424
+ if vel_global is not None:
425
+ 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])}
428
+ else:
429
+ velocity = None
430
+
431
+ ann_record = {
432
+ "sample_token": new_sample_token,
433
+ "category": category,
434
+ "bbox_3d": {
435
+ "translation": translation_to_named(t_local.tolist()),
436
+ "size": size_wlh_to_named(ann["size"]),
437
+ "rotation": quat_wxyz_to_named(q_ann_local),
438
+ },
439
+ "num_lidar_pts": ann["num_lidar_pts"],
440
+ "visibility": visibility,
441
+ "velocity": velocity,
442
+ "attributes": attributes,
443
+ "prev": None,
444
+ "next": None,
445
+ "_nusc_instance_token": nusc_instance_token,
446
+ "_nusc_ann_token": ann_token,
447
+ "_timestamp_us": sample["timestamp"],
448
+ }
449
+
450
+ if nusc_instance_token not in instance_anns:
451
+ instance_anns[nusc_instance_token] = []
452
+ instance_anns[nusc_instance_token].append(ann_record)
453
+ annotations.append(ann_record)
454
+
455
+ sample_token = sample["next"]
456
+
457
+ # Pass 2: Generate tokens and build prev/next chains
458
+ instances: list[dict] = []
459
+ for nusc_inst_token, anns in instance_anns.items():
460
+ anns.sort(key=lambda a: a["_timestamp_us"])
461
+ category = anns[0]["category"]
462
+ first_ts = anns[0]["_timestamp_us"]
463
+ inst_token = generate_token(clip_id, f"inst:{category}:{first_ts}")
464
+
465
+ ann_tokens = []
466
+ for ann in anns:
467
+ ann["instance_token"] = inst_token
468
+ ann["annotation_token"] = generate_token(
469
+ clip_id, f"ann:{ann['sample_token']}:{inst_token}"
470
+ )
471
+ ann_tokens.append(ann["annotation_token"])
472
+
473
+ for i, ann in enumerate(anns):
474
+ ann["prev"] = ann_tokens[i - 1] if i > 0 else None
475
+ ann["next"] = ann_tokens[i + 1] if i < len(anns) - 1 else None
476
+
477
+ instances.append(
478
+ {
479
+ "instance_token": inst_token,
480
+ "category": category,
481
+ "num_annotations": len(anns),
482
+ "first_annotation_token": ann_tokens[0],
483
+ "last_annotation_token": ann_tokens[-1],
484
+ }
485
+ )
486
+
487
+ for ann in annotations:
488
+ del ann["_nusc_instance_token"]
489
+ del ann["_nusc_ann_token"]
490
+ del ann["_timestamp_us"]
491
+
492
+ return annotations, instances
493
+
494
+ def _extract_samples(
495
+ self, scene: dict, clip_id: str
496
+ ) -> list[dict]:
497
+ """Extract sample (keyframe-only) records for the scene."""
498
+ records: list[dict] = []
499
+ sample_token = scene["first_sample_token"]
500
+ while sample_token:
501
+ sample = self.nusc.get("sample", sample_token)
502
+ records.append(
503
+ {
504
+ "sample_token": generate_token(clip_id, str(sample["timestamp"])),
505
+ "clip_id": clip_id,
506
+ "timestamp_us": sample["timestamp"],
507
+ "is_keyframe": True,
508
+ "prev": None,
509
+ "next": None,
510
+ "_nusc_token": sample_token,
511
+ }
512
+ )
513
+ sample_token = sample["next"]
514
+
515
+ # Build prev/next chain
516
+ for i in range(len(records)):
517
+ if i > 0:
518
+ records[i]["prev"] = records[i - 1]["sample_token"]
519
+ if i < len(records) - 1:
520
+ records[i]["next"] = records[i + 1]["sample_token"]
521
+
522
+ return records
523
+
524
+ def _extract_sample_data(
525
+ self,
526
+ scene: dict,
527
+ clip_id: str,
528
+ ep_old_to_new: dict[str, str],
529
+ ) -> list[dict]:
530
+ """Extract all sample_data (keyframe + sweep) for target channels."""
531
+ # Build sample token mapping
532
+ sample_token_map: dict[str, str] = {}
533
+ sample_token = scene["first_sample_token"]
534
+ while sample_token:
535
+ sample = self.nusc.get("sample", sample_token)
536
+ sample_token_map[sample_token] = generate_token(
537
+ clip_id, str(sample["timestamp"])
538
+ )
539
+ sample_token = sample["next"]
540
+
541
+ # Build cs token mapping
542
+ cs_token_map: dict[str, str] = {}
543
+ first_sample = self.nusc.get("sample", scene["first_sample_token"])
544
+ for channel, sd_token in first_sample["data"].items():
545
+ if channel not in TARGET_CHANNELS:
546
+ continue
547
+ sd = self.nusc.get("sample_data", sd_token)
548
+ cs_token_map[sd["calibrated_sensor_token"]] = generate_token(
549
+ clip_id, f"cs:{channel}"
550
+ )
551
+
552
+ # Collect all sample_data per channel, then build prev/next
553
+ per_channel: dict[str, list[dict]] = {}
554
+
555
+ sample_token = scene["first_sample_token"]
556
+ while sample_token:
557
+ sample = self.nusc.get("sample", sample_token)
558
+ new_sample_token = sample_token_map[sample_token]
559
+
560
+ for channel, sd_token in sample["data"].items():
561
+ if channel not in TARGET_CHANNELS:
562
+ continue
563
+ if channel not in per_channel:
564
+ per_channel[channel] = []
565
+
566
+ # Walk from this keyframe sd through sweeps to next keyframe
567
+ cur_sd_token = sd_token
568
+ while cur_sd_token:
569
+ sd = self.nusc.get("sample_data", cur_sd_token)
570
+ ch = self._get_channel(sd["calibrated_sensor_token"])
571
+ if ch != channel:
572
+ break
573
+
574
+ is_kf = sd["is_key_frame"]
575
+ ts = sd["timestamp"]
576
+ prefix = "samples" if is_kf else "sweeps"
577
+ ext = ".bin" if ch == "LIDAR_TOP" else ".jpg"
578
+ file_path = f"{prefix}/{ch}/{ts}{ext}"
579
+
580
+ per_channel[channel].append(
581
+ {
582
+ "sample_data_token": generate_token(
583
+ clip_id, f"{ch}:{ts}"
584
+ ),
585
+ "sample_token": new_sample_token,
586
+ "ego_pose_token": ep_old_to_new[sd["ego_pose_token"]],
587
+ "calibrated_sensor_token": cs_token_map[
588
+ sd["calibrated_sensor_token"]
589
+ ],
590
+ "channel": ch,
591
+ "file_path": file_path,
592
+ "timestamp_us": ts,
593
+ "is_keyframe": is_kf,
594
+ "width": sd["width"],
595
+ "height": sd["height"],
596
+ "prev": None,
597
+ "next": None,
598
+ "_nusc_filename": sd["filename"],
599
+ }
600
+ )
601
+
602
+ next_sd_token = sd["next"]
603
+ if next_sd_token:
604
+ next_sd = self.nusc.get("sample_data", next_sd_token)
605
+ if next_sd["is_key_frame"]:
606
+ break
607
+ cur_sd_token = next_sd_token
608
+
609
+ sample_token = sample["next"]
610
+
611
+ # Build per-channel prev/next chains and flatten
612
+ all_records: list[dict] = []
613
+ for channel, records in per_channel.items():
614
+ records.sort(key=lambda r: r["timestamp_us"])
615
+ for i in range(len(records)):
616
+ if i > 0:
617
+ records[i]["prev"] = records[i - 1]["sample_data_token"]
618
+ if i < len(records) - 1:
619
+ records[i]["next"] = records[i + 1]["sample_data_token"]
620
+ all_records.extend(records)
621
+
622
+ return all_records
623
+
624
+ def convert_scene(self, scene_token: str) -> str:
625
+ """Convert a single nuScenes scene to an X4D clip directory.
626
+
627
+ Returns the clip_id.
628
+ """
629
+ scene = self.nusc.get("scene", scene_token)
630
+
631
+ # 1. Extract metadata
632
+ clip_id, meta = self._extract_metadata(scene)
633
+ clip_dir = self.output_dir / "clips" / clip_id
634
+
635
+ # 2. Extract calibrated sensors
636
+ cs_records = self._extract_calibrated_sensors(scene, clip_id)
637
+
638
+ # 3. Extract ego poses (local coords)
639
+ ego_poses = self._extract_ego_poses(scene, clip_id)
640
+ ep_old_to_new = {ep["_original_token"]: ep["ego_pose_token"] for ep in ego_poses}
641
+
642
+ # 4. Extract samples (keyframe only)
643
+ samples = self._extract_samples(scene, clip_id)
644
+
645
+ # 5. Extract sample_data (keyframe + sweep)
646
+ sample_data_records = self._extract_sample_data(scene, clip_id, ep_old_to_new)
647
+
648
+ # 6. Extract annotations + instances
649
+ annotations, instances = self._extract_annotations(scene, clip_id)
650
+
651
+ # 7. Create directory structure
652
+ clip_dir.mkdir(parents=True, exist_ok=True)
653
+ for channel in TARGET_CHANNELS:
654
+ (clip_dir / "samples" / channel).mkdir(parents=True, exist_ok=True)
655
+ (clip_dir / "sweeps" / channel).mkdir(parents=True, exist_ok=True)
656
+
657
+ # 8. Copy sensor files
658
+ for sd in sample_data_records:
659
+ src = self.dataroot / sd["_nusc_filename"]
660
+ dst = clip_dir / sd["file_path"]
661
+ if src.is_file():
662
+ shutil.copy2(src, dst)
663
+
664
+ # 9. Clean internal fields before writing JSON
665
+ for ep in ego_poses:
666
+ del ep["_original_token"]
667
+ del ep["_is_keyframe"]
668
+ for sd in sample_data_records:
669
+ del sd["_nusc_filename"]
670
+ for s in samples:
671
+ del s["_nusc_token"]
672
+
673
+ # 10. Write JSON files
674
+ self._write_json(clip_dir / "meta.json", meta)
675
+ self._write_json(clip_dir / "calibrated_sensor.json", cs_records)
676
+ self._write_json(clip_dir / "ego_pose.json", ego_poses)
677
+ self._write_json(clip_dir / "sample.json", samples)
678
+ self._write_json(clip_dir / "sample_data.json", sample_data_records)
679
+ self._write_json(clip_dir / "annotation.json", annotations)
680
+ self._write_json(clip_dir / "instance.json", instances)
681
+
682
+ return clip_id
683
+
684
+ def convert(
685
+ self,
686
+ scenes: list[str] | None = None,
687
+ workers: int = 0,
688
+ ) -> list[str]:
689
+ """Convert nuScenes scenes to X4D clips.
690
+
691
+ Args:
692
+ scenes: List of scene names to convert (e.g. ["scene-0061"]).
693
+ None converts all scenes.
694
+ workers: Not yet implemented. Reserved for future multiprocessing.
695
+
696
+ Returns:
697
+ List of clip_ids created.
698
+ """
699
+ target_scenes = self.nusc.scene
700
+ if scenes:
701
+ scene_names = set(scenes)
702
+ target_scenes = [s for s in target_scenes if s["name"] in scene_names]
703
+
704
+ clip_ids = []
705
+ for scene in target_scenes:
706
+ clip_id = self.convert_scene(scene["token"])
707
+ clip_ids.append(clip_id)
708
+
709
+ return clip_ids
710
+
711
+ @staticmethod
712
+ def _write_json(path: Path, data: Any) -> None:
713
+ """Write data to JSON file with consistent formatting."""
714
+ with open(path, "w") as f:
715
+ json.dump(data, f, indent=2, ensure_ascii=False)