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,28 @@
1
+ """Core layer: data models, clip loader, transforms."""
2
+
3
+ from x4d_devkit.core.loader import ClipLoader
4
+ from x4d_devkit.core.models import (
5
+ Annotation,
6
+ CalibratedSensor,
7
+ ClipMeta,
8
+ EgoPose,
9
+ Instance,
10
+ Sample,
11
+ SampleData,
12
+ )
13
+ from x4d_devkit.core.token import generate_token
14
+ from x4d_devkit.core.transform import Transform, TransformChain
15
+
16
+ __all__ = [
17
+ "Annotation",
18
+ "CalibratedSensor",
19
+ "ClipLoader",
20
+ "ClipMeta",
21
+ "EgoPose",
22
+ "Instance",
23
+ "Sample",
24
+ "SampleData",
25
+ "Transform",
26
+ "TransformChain",
27
+ "generate_token",
28
+ ]
@@ -0,0 +1,259 @@
1
+ """ClipLoader: load an X4D clip directory into typed dataclasses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+
11
+ from x4d_devkit.core.models import (
12
+ ClipMeta,
13
+ CalibratedSensor,
14
+ EgoPose,
15
+ Sample,
16
+ SampleData,
17
+ Annotation,
18
+ Instance,
19
+ )
20
+
21
+
22
+ class ClipLoader:
23
+ """Load and index an X4D clip directory.
24
+
25
+ Eagerly parses all JSON metadata into frozen dataclasses.
26
+ Binary sensor data (point clouds, images) is loaded on demand.
27
+ """
28
+
29
+ def __init__(self, clip_dir: str | Path) -> None:
30
+ self._clip_dir = Path(clip_dir)
31
+
32
+ # Load and parse all JSON
33
+ self._meta = self._load_meta()
34
+ self._calibrated_sensors = self._load_calibrated_sensors()
35
+ self._ego_poses = self._load_ego_poses()
36
+ self._samples = self._load_samples()
37
+ self._sample_data = self._load_sample_data()
38
+ self._annotations = self._load_annotations()
39
+ self._instances = self._load_instances()
40
+
41
+ # Build global token → object index
42
+ self._token_index: dict[str, Any] = {}
43
+ for cs in self._calibrated_sensors:
44
+ self._token_index[cs.token] = cs
45
+ for ep in self._ego_poses:
46
+ self._token_index[ep.token] = ep
47
+ for s in self._samples:
48
+ self._token_index[s.token] = s
49
+ for sd in self._sample_data:
50
+ self._token_index[sd.token] = sd
51
+ for a in self._annotations:
52
+ self._token_index[a.token] = a
53
+ for i in self._instances:
54
+ self._token_index[i.token] = i
55
+
56
+ # Build auxiliary indices
57
+ self._sd_by_channel: dict[str, list[SampleData]] = {}
58
+ for sd in self._sample_data:
59
+ self._sd_by_channel.setdefault(sd.channel, []).append(sd)
60
+ for ch in self._sd_by_channel:
61
+ self._sd_by_channel[ch].sort(key=lambda sd: sd.timestamp_us)
62
+
63
+ self._sd_by_sample: dict[str, list[SampleData]] = {}
64
+ for sd in self._sample_data:
65
+ self._sd_by_sample.setdefault(sd.sample_token, []).append(sd)
66
+
67
+ self._ann_by_sample: dict[str, list[Annotation]] = {}
68
+ for a in self._annotations:
69
+ self._ann_by_sample.setdefault(a.sample_token, []).append(a)
70
+
71
+ def _read_json(self, name: str) -> Any:
72
+ with open(self._clip_dir / name) as f:
73
+ return json.load(f)
74
+
75
+ def _load_meta(self) -> ClipMeta:
76
+ d = self._read_json("meta.json")
77
+ return ClipMeta(
78
+ clip_id=d["clip_id"],
79
+ schema_version=d["schema_version"],
80
+ session_id=d["session_id"],
81
+ vehicle_id=d["vehicle_id"],
82
+ capture_time_utc=d["capture_time_utc"],
83
+ duration_s=d["duration_s"],
84
+ num_keyframes=d["keyframe_count"],
85
+ num_sweeps=d["sweep_count"],
86
+ sensors=d["sensors"],
87
+ )
88
+
89
+ def _load_calibrated_sensors(self) -> list[CalibratedSensor]:
90
+ records = self._read_json("calibrated_sensor.json")
91
+ result = []
92
+ for r in records:
93
+ t = r["translation"]
94
+ q = r["rotation"]
95
+ result.append(CalibratedSensor(
96
+ token=r["calibrated_sensor_token"],
97
+ channel=r["channel"],
98
+ modality="lidar" if r["camera_intrinsic"] is None else "camera",
99
+ translation=[t["x"], t["y"], t["z"]],
100
+ rotation=[q["qx"], q["qy"], q["qz"], q["qw"]],
101
+ camera_intrinsic=r["camera_intrinsic"],
102
+ ))
103
+ return result
104
+
105
+ def _load_ego_poses(self) -> list[EgoPose]:
106
+ records = self._read_json("ego_pose.json")
107
+ result = []
108
+ for r in records:
109
+ t = r["translation"]
110
+ q = r["rotation"]
111
+ result.append(EgoPose(
112
+ token=r["ego_pose_token"],
113
+ timestamp_us=r["timestamp_us"],
114
+ translation=[t["x"], t["y"], t["z"]],
115
+ rotation=[q["qx"], q["qy"], q["qz"], q["qw"]],
116
+ ))
117
+ result.sort(key=lambda ep: ep.timestamp_us)
118
+ return result
119
+
120
+ def _load_samples(self) -> list[Sample]:
121
+ records = self._read_json("sample.json")
122
+ result = []
123
+ for r in records:
124
+ result.append(Sample(
125
+ token=r["sample_token"],
126
+ timestamp_us=r["timestamp_us"],
127
+ is_keyframe=r["is_keyframe"],
128
+ prev=r["prev"] or "",
129
+ next=r["next"] or "",
130
+ ))
131
+ result.sort(key=lambda s: s.timestamp_us)
132
+ return result
133
+
134
+ def _load_sample_data(self) -> list[SampleData]:
135
+ records = self._read_json("sample_data.json")
136
+ result = []
137
+ for r in records:
138
+ result.append(SampleData(
139
+ token=r["sample_data_token"],
140
+ sample_token=r["sample_token"],
141
+ channel=r["channel"],
142
+ timestamp_us=r["timestamp_us"],
143
+ file_path=r["file_path"],
144
+ ego_pose_token=r["ego_pose_token"],
145
+ calibrated_sensor_token=r["calibrated_sensor_token"],
146
+ is_keyframe=r["is_keyframe"],
147
+ width=r.get("width"),
148
+ height=r.get("height"),
149
+ prev=r["prev"] or "",
150
+ next=r["next"] or "",
151
+ ))
152
+ return result
153
+
154
+ def _load_annotations(self) -> list[Annotation]:
155
+ records = self._read_json("annotation.json")
156
+ result = []
157
+ for r in records:
158
+ bbox = r["bbox_3d"]
159
+ t = bbox["translation"]
160
+ s = bbox["size"]
161
+ q = bbox["rotation"]
162
+ vel = r.get("velocity")
163
+ result.append(Annotation(
164
+ token=r["annotation_token"],
165
+ sample_token=r["sample_token"],
166
+ instance_token=r["instance_token"],
167
+ category=r["category"],
168
+ translation=[t["x"], t["y"], t["z"]],
169
+ size=[s["length"], s["width"], s["height"]],
170
+ rotation=[q["qx"], q["qy"], q["qz"], q["qw"]],
171
+ num_lidar_pts=r["num_lidar_pts"],
172
+ visibility=str(r["visibility"]) if r["visibility"] is not None else "",
173
+ velocity=[vel["vx"], vel["vy"], vel["vz"]] if vel else None,
174
+ attributes=r.get("attributes", []),
175
+ prev=r["prev"] or "",
176
+ next=r["next"] or "",
177
+ score=r.get("score"),
178
+ ))
179
+ return result
180
+
181
+ def _load_instances(self) -> list[Instance]:
182
+ records = self._read_json("instance.json")
183
+ return [
184
+ Instance(
185
+ token=r["instance_token"],
186
+ category=r["category"],
187
+ num_annotations=r["num_annotations"],
188
+ first_annotation_token=r["first_annotation_token"],
189
+ last_annotation_token=r["last_annotation_token"],
190
+ )
191
+ for r in records
192
+ ]
193
+
194
+ # --- Public API ---
195
+
196
+ @property
197
+ def clip_dir(self) -> Path:
198
+ return self._clip_dir
199
+
200
+ @property
201
+ def meta(self) -> ClipMeta:
202
+ return self._meta
203
+
204
+ @property
205
+ def samples(self) -> list[Sample]:
206
+ return self._samples
207
+
208
+ @property
209
+ def sample_data(self) -> list[SampleData]:
210
+ return self._sample_data
211
+
212
+ @property
213
+ def ego_poses(self) -> list[EgoPose]:
214
+ return self._ego_poses
215
+
216
+ @property
217
+ def calibrated_sensors(self) -> list[CalibratedSensor]:
218
+ return self._calibrated_sensors
219
+
220
+ @property
221
+ def annotations(self) -> list[Annotation]:
222
+ return self._annotations
223
+
224
+ @property
225
+ def instances(self) -> list[Instance]:
226
+ return self._instances
227
+
228
+ def get(self, token: str) -> Any:
229
+ """O(1) lookup by token across all entity types. Raises KeyError if not found."""
230
+ return self._token_index[token]
231
+
232
+ def sample_data_for_channel(self, channel: str) -> list[SampleData]:
233
+ """All sample_data for a channel, sorted by timestamp."""
234
+ return self._sd_by_channel.get(channel, [])
235
+
236
+ def sample_data_for_sample(self, sample_token: str) -> list[SampleData]:
237
+ """All sample_data records for a given sample (all channels at that timestamp)."""
238
+ return self._sd_by_sample.get(sample_token, [])
239
+
240
+ def annotations_for_sample(self, sample_token: str) -> list[Annotation]:
241
+ """All annotations for a given sample."""
242
+ return self._ann_by_sample.get(sample_token, [])
243
+
244
+ def load_point_cloud(self, sd: SampleData) -> np.ndarray:
245
+ """Load point cloud binary file as (N, C) float32 array.
246
+
247
+ Uses meta.sensors[channel].point_format to determine stride.
248
+ """
249
+ sensor_info = self._meta.sensors[sd.channel]
250
+ pf = sensor_info["point_format"]
251
+ num_fields = len(pf["fields"])
252
+
253
+ path = self._clip_dir / sd.file_path
254
+ raw = np.fromfile(str(path), dtype=np.float32)
255
+ return raw.reshape(-1, num_fields)
256
+
257
+ def load_image_path(self, sd: SampleData) -> Path:
258
+ """Return the absolute path to the image file (does not load pixels)."""
259
+ return self._clip_dir / sd.file_path
@@ -0,0 +1,106 @@
1
+ """Frozen dataclass models for X4D dataset entities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class ClipMeta:
13
+ clip_id: str
14
+ schema_version: str
15
+ session_id: str
16
+ vehicle_id: str
17
+ capture_time_utc: str
18
+ duration_s: float
19
+ num_keyframes: int
20
+ num_sweeps: int
21
+ sensors: dict[str, dict[str, Any]]
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class CalibratedSensor:
26
+ token: str
27
+ channel: str
28
+ modality: str
29
+ translation: np.ndarray
30
+ rotation: np.ndarray
31
+ camera_intrinsic: dict[str, float] | None
32
+
33
+ def __post_init__(self) -> None:
34
+ object.__setattr__(self, "translation", np.asarray(self.translation, dtype=np.float64))
35
+ object.__setattr__(self, "rotation", np.asarray(self.rotation, dtype=np.float64))
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class EgoPose:
40
+ token: str
41
+ timestamp_us: int
42
+ translation: np.ndarray
43
+ rotation: np.ndarray
44
+
45
+ def __post_init__(self) -> None:
46
+ object.__setattr__(self, "translation", np.asarray(self.translation, dtype=np.float64))
47
+ object.__setattr__(self, "rotation", np.asarray(self.rotation, dtype=np.float64))
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class Sample:
52
+ token: str
53
+ timestamp_us: int
54
+ is_keyframe: bool
55
+ prev: str
56
+ next: str
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class SampleData:
61
+ token: str
62
+ sample_token: str
63
+ channel: str
64
+ timestamp_us: int
65
+ file_path: str
66
+ ego_pose_token: str
67
+ calibrated_sensor_token: str
68
+ is_keyframe: bool
69
+ width: int | None
70
+ height: int | None
71
+ prev: str
72
+ next: str
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class Annotation:
77
+ token: str
78
+ sample_token: str
79
+ instance_token: str
80
+ category: str
81
+ translation: np.ndarray
82
+ size: np.ndarray
83
+ rotation: np.ndarray
84
+ num_lidar_pts: int
85
+ visibility: str
86
+ velocity: np.ndarray | None
87
+ attributes: list[str]
88
+ prev: str
89
+ next: str
90
+ score: float | None = None
91
+
92
+ def __post_init__(self) -> None:
93
+ object.__setattr__(self, "translation", np.asarray(self.translation, dtype=np.float64))
94
+ object.__setattr__(self, "size", np.asarray(self.size, dtype=np.float64))
95
+ object.__setattr__(self, "rotation", np.asarray(self.rotation, dtype=np.float64))
96
+ if self.velocity is not None:
97
+ object.__setattr__(self, "velocity", np.asarray(self.velocity, dtype=np.float64))
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class Instance:
102
+ token: str
103
+ category: str
104
+ num_annotations: int
105
+ first_annotation_token: str
106
+ last_annotation_token: str
@@ -0,0 +1,26 @@
1
+ """Deterministic token generation for X4D dataset format.
2
+
3
+ All tokens are md5(clip_id + ":" + discriminator), truncated to 16 hex chars.
4
+ """
5
+
6
+ import hashlib
7
+
8
+
9
+ def generate_token(clip_id: str, discriminator: str) -> str:
10
+ """Generate a deterministic 16-char hex token.
11
+
12
+ Args:
13
+ clip_id: The clip identifier (e.g. "20180724_n015_scene-0061_000").
14
+ discriminator: Type-specific suffix. Convention:
15
+ - sample: str(timestamp_us)
16
+ - sample_data: f"{channel}:{timestamp_us}"
17
+ - ego_pose: f"ego:{timestamp_us}"
18
+ - calibrated_sensor: f"cs:{channel}"
19
+ - annotation: f"ann:{sample_token}:{instance_token}"
20
+ - instance: f"inst:{category}:{first_timestamp_us}"
21
+
22
+ Returns:
23
+ 16-character lowercase hex string.
24
+ """
25
+ raw = f"{clip_id}:{discriminator}"
26
+ return hashlib.md5(raw.encode()).hexdigest()[:16]
@@ -0,0 +1,159 @@
1
+ """SE3 transform utilities for X4D coordinate systems.
2
+
3
+ Quaternion convention: [qx, qy, qz, qw] (X4D standard).
4
+ Transform naming convention: dst_from_src (e.g. world_from_ego).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+
11
+ import numpy as np
12
+
13
+
14
+ def quat_to_rotation_matrix(qx: float, qy: float, qz: float, qw: float) -> np.ndarray:
15
+ """Convert [qx,qy,qz,qw] quaternion to 3x3 rotation matrix.
16
+
17
+ Normalizes the quaternion before conversion.
18
+ """
19
+ q = np.array([qx, qy, qz, qw], dtype=float)
20
+ q = q / np.linalg.norm(q)
21
+ x, y, z, w = q
22
+ return np.array(
23
+ [
24
+ [1 - 2 * (y * y + z * z), 2 * (x * y - w * z), 2 * (x * z + w * y)],
25
+ [2 * (x * y + w * z), 1 - 2 * (x * x + z * z), 2 * (y * z - w * x)],
26
+ [2 * (x * z - w * y), 2 * (y * z + w * x), 1 - 2 * (x * x + y * y)],
27
+ ]
28
+ )
29
+
30
+
31
+ def rotation_matrix_to_quat(R: np.ndarray) -> tuple[float, float, float, float]:
32
+ """Convert 3x3 rotation matrix to (qx, qy, qz, qw) tuple."""
33
+ trace = R[0, 0] + R[1, 1] + R[2, 2]
34
+ if trace > 0:
35
+ s = 0.5 / np.sqrt(trace + 1.0)
36
+ w = 0.25 / s
37
+ x = (R[2, 1] - R[1, 2]) * s
38
+ y = (R[0, 2] - R[2, 0]) * s
39
+ z = (R[1, 0] - R[0, 1]) * s
40
+ elif R[0, 0] > R[1, 1] and R[0, 0] > R[2, 2]:
41
+ s = 2.0 * np.sqrt(1.0 + R[0, 0] - R[1, 1] - R[2, 2])
42
+ w = (R[2, 1] - R[1, 2]) / s
43
+ x = 0.25 * s
44
+ y = (R[0, 1] + R[1, 0]) / s
45
+ z = (R[0, 2] + R[2, 0]) / s
46
+ elif R[1, 1] > R[2, 2]:
47
+ s = 2.0 * np.sqrt(1.0 + R[1, 1] - R[0, 0] - R[2, 2])
48
+ w = (R[0, 2] - R[2, 0]) / s
49
+ x = (R[0, 1] + R[1, 0]) / s
50
+ y = 0.25 * s
51
+ z = (R[1, 2] + R[2, 1]) / s
52
+ else:
53
+ s = 2.0 * np.sqrt(1.0 + R[2, 2] - R[0, 0] - R[1, 1])
54
+ w = (R[1, 0] - R[0, 1]) / s
55
+ x = (R[0, 2] + R[2, 0]) / s
56
+ y = (R[1, 2] + R[2, 1]) / s
57
+ z = 0.25 * s
58
+ return (float(x), float(y), float(z), float(w))
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class Transform:
63
+ """SE3 rigid-body transform.
64
+
65
+ Naming convention: variable name should read as dst_from_src.
66
+ Example: world_from_ego = Transform(...) means p_world = T @ p_ego.
67
+ """
68
+
69
+ rotation: np.ndarray # (3, 3) rotation matrix
70
+ translation: np.ndarray # (3,)
71
+
72
+ def __post_init__(self) -> None:
73
+ object.__setattr__(self, "rotation", np.asarray(self.rotation, dtype=float))
74
+ object.__setattr__(self, "translation", np.asarray(self.translation, dtype=float))
75
+
76
+ @classmethod
77
+ def identity(cls) -> Transform:
78
+ return cls(rotation=np.eye(3), translation=np.zeros(3))
79
+
80
+ @classmethod
81
+ def from_quat(
82
+ cls,
83
+ translation: np.ndarray | list[float],
84
+ quat: np.ndarray | list[float],
85
+ ) -> Transform:
86
+ """Construct from translation (3,) and quaternion [qx, qy, qz, qw]."""
87
+ q = np.asarray(quat, dtype=float)
88
+ R = quat_to_rotation_matrix(q[0], q[1], q[2], q[3])
89
+ return cls(rotation=R, translation=np.asarray(translation, dtype=float))
90
+
91
+ @property
92
+ def inverse(self) -> Transform:
93
+ R_inv = self.rotation.T
94
+ t_inv = -R_inv @ self.translation
95
+ return Transform(rotation=R_inv, translation=t_inv)
96
+
97
+ def __matmul__(self, other: Transform) -> Transform:
98
+ """Compose transforms: (A @ B).apply(p) == A.apply(B.apply(p))."""
99
+ R = self.rotation @ other.rotation
100
+ t = self.rotation @ other.translation + self.translation
101
+ return Transform(rotation=R, translation=t)
102
+
103
+ def apply(self, points: np.ndarray) -> np.ndarray:
104
+ """Transform points (N, 3) or (3,) -> same shape."""
105
+ return (self.rotation @ points.T).T + self.translation
106
+
107
+ @property
108
+ def as_matrix(self) -> np.ndarray:
109
+ """Return (4, 4) homogeneous transformation matrix."""
110
+ M = np.eye(4)
111
+ M[:3, :3] = self.rotation
112
+ M[:3, 3] = self.translation
113
+ return M
114
+
115
+ @property
116
+ def quat(self) -> np.ndarray:
117
+ """Return quaternion as [qx, qy, qz, qw]."""
118
+ return np.array(rotation_matrix_to_quat(self.rotation))
119
+
120
+
121
+ from typing import TYPE_CHECKING
122
+
123
+ if TYPE_CHECKING:
124
+ from x4d_devkit.core.loader import ClipLoader
125
+ from x4d_devkit.core.models import SampleData
126
+
127
+
128
+ class TransformChain:
129
+ """Build coordinate transform chains from a loaded clip.
130
+
131
+ Composes sensor → ego → world transforms using calibration
132
+ and ego_pose data from ClipLoader.
133
+ """
134
+
135
+ def __init__(self, loader: ClipLoader) -> None:
136
+ self._loader = loader
137
+ self._cs_by_channel = {cs.channel: cs for cs in loader.calibrated_sensors}
138
+
139
+ def get_ego_from_sensor(self, channel: str) -> Transform:
140
+ """Get the static sensor-to-ego (base_link) transform for a channel."""
141
+ cs = self._cs_by_channel[channel]
142
+ return Transform.from_quat(cs.translation, cs.rotation)
143
+
144
+ def get_world_from_ego(self, ego_pose_token: str) -> Transform:
145
+ """Get the ego-to-world transform for a specific ego_pose."""
146
+ ep = self._loader.get(ego_pose_token)
147
+ return Transform.from_quat(ep.translation, ep.rotation)
148
+
149
+ def get_world_from_sensor(self, sd: SampleData) -> Transform:
150
+ """Get the full sensor-to-world transform for a sample_data record."""
151
+ return self.get_world_from_ego(sd.ego_pose_token) @ self.get_ego_from_sensor(sd.channel)
152
+
153
+ def transform_points_to_world(self, points: np.ndarray, sd: SampleData) -> np.ndarray:
154
+ """Transform points (N, 3) from sensor coordinates to world coordinates."""
155
+ return self.get_world_from_sensor(sd).apply(points)
156
+
157
+ def transform_points_to_sensor(self, points: np.ndarray, sd: SampleData) -> np.ndarray:
158
+ """Transform points (N, 3) from world coordinates to sensor coordinates."""
159
+ return self.get_world_from_sensor(sd).inverse.apply(points)
@@ -0,0 +1,17 @@
1
+ """Evaluation layer for X4D dataset."""
2
+
3
+ from x4d_devkit.eval.detection import (
4
+ DetectionConfig,
5
+ DetectionEval,
6
+ DetectionResult,
7
+ detzero_detection_config,
8
+ nusc_detection_config,
9
+ )
10
+
11
+ __all__ = [
12
+ "DetectionConfig",
13
+ "DetectionEval",
14
+ "DetectionResult",
15
+ "detzero_detection_config",
16
+ "nusc_detection_config",
17
+ ]