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,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
|
x4d_devkit/core/token.py
ADDED
|
@@ -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
|
+
]
|