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.
- x4d_devkit/__init__.py +42 -0
- x4d_devkit/cli.py +131 -0
- x4d_devkit/client/__init__.py +3 -0
- x4d_devkit/client/client.py +56 -0
- x4d_devkit/client/clips.py +135 -0
- x4d_devkit/client/projects.py +25 -0
- x4d_devkit/converters/__init__.py +1 -0
- x4d_devkit/converters/nuscenes.py +715 -0
- x4d_devkit/core/__init__.py +28 -0
- x4d_devkit/core/loader.py +259 -0
- x4d_devkit/core/models.py +106 -0
- x4d_devkit/core/token.py +26 -0
- x4d_devkit/core/transform.py +159 -0
- x4d_devkit/eval/__init__.py +17 -0
- x4d_devkit/eval/detection.py +350 -0
- x4d_devkit/eval/matching.py +184 -0
- x4d_devkit/eval/metrics.py +83 -0
- x4d_devkit/validation/__init__.py +4 -0
- x4d_devkit/validation/report.py +46 -0
- x4d_devkit/validation/validator.py +236 -0
- x4d_devkit-0.1.0.dist-info/METADATA +117 -0
- x4d_devkit-0.1.0.dist-info/RECORD +26 -0
- x4d_devkit-0.1.0.dist-info/WHEEL +5 -0
- x4d_devkit-0.1.0.dist-info/entry_points.txt +2 -0
- x4d_devkit-0.1.0.dist-info/licenses/LICENSE +190 -0
- x4d_devkit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|