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,350 @@
1
+ """3D object detection evaluation: mAP, TP metrics, NDS.
2
+
3
+ The evaluator is **not** tied to any fixed set of categories.
4
+ All class names, distance ranges, and category mappings are injected
5
+ via ``DetectionConfig``. Two ready-made presets are provided:
6
+
7
+ * ``nusc_detection_config()`` — nuScenes CVPR 2019 (10 classes)
8
+ * ``detzero_detection_config()`` — DetZero 3-class (Vehicle / Pedestrian / Cyclist)
9
+
10
+ Users can create their own ``DetectionConfig`` for any category set.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+ from typing import TYPE_CHECKING
17
+
18
+ import numpy as np
19
+
20
+ from x4d_devkit.eval.matching import MetricData, accumulate
21
+ from x4d_devkit.eval.metrics import center_distance
22
+
23
+ if TYPE_CHECKING:
24
+ from x4d_devkit.core.loader import ClipLoader
25
+
26
+ TP_METRICS = ["trans_err", "scale_err", "orient_err", "vel_err", "attr_err"]
27
+
28
+ # ── Presets (convenience constants, NOT used by the evaluator directly) ──
29
+
30
+ NUSC_DETECTION_NAMES = [
31
+ "car", "truck", "bus", "trailer", "construction_vehicle",
32
+ "pedestrian", "motorcycle", "bicycle", "traffic_cone", "barrier",
33
+ ]
34
+
35
+ NUSC_CLASS_RANGE = {
36
+ "car": 50, "truck": 50, "bus": 50, "trailer": 50,
37
+ "construction_vehicle": 50, "pedestrian": 40, "motorcycle": 40,
38
+ "bicycle": 40, "traffic_cone": 30, "barrier": 30,
39
+ }
40
+
41
+ NUSC_CATEGORY_MAP: dict[str, str] = {
42
+ "vehicle.car": "car",
43
+ "vehicle.truck": "truck",
44
+ "vehicle.bus.bendy": "bus",
45
+ "vehicle.bus.rigid": "bus",
46
+ "vehicle.trailer": "trailer",
47
+ "vehicle.construction": "construction_vehicle",
48
+ "human.pedestrian.adult": "pedestrian",
49
+ "human.pedestrian.child": "pedestrian",
50
+ "human.pedestrian.wheelchair": "pedestrian",
51
+ "human.pedestrian.stroller": "pedestrian",
52
+ "human.pedestrian.personal_mobility": "pedestrian",
53
+ "human.pedestrian.police_officer": "pedestrian",
54
+ "human.pedestrian.construction_worker": "pedestrian",
55
+ "vehicle.motorcycle": "motorcycle",
56
+ "vehicle.bicycle": "bicycle",
57
+ "movable_object.trafficcone": "traffic_cone",
58
+ "movable_object.barrier": "barrier",
59
+ }
60
+
61
+ DETZERO_DETECTION_NAMES = ["Vehicle", "Pedestrian", "Cyclist"]
62
+
63
+ DETZERO_CLASS_RANGE = {
64
+ "Vehicle": 75, "Pedestrian": 50, "Cyclist": 50,
65
+ }
66
+
67
+ DETZERO_CATEGORY_MAP: dict[str, str] = {
68
+ # nuScenes full names → DetZero classes
69
+ "vehicle.car": "Vehicle",
70
+ "vehicle.truck": "Vehicle",
71
+ "vehicle.bus.bendy": "Vehicle",
72
+ "vehicle.bus.rigid": "Vehicle",
73
+ "vehicle.trailer": "Vehicle",
74
+ "vehicle.construction": "Vehicle",
75
+ "human.pedestrian.adult": "Pedestrian",
76
+ "human.pedestrian.child": "Pedestrian",
77
+ "human.pedestrian.wheelchair": "Pedestrian",
78
+ "human.pedestrian.stroller": "Pedestrian",
79
+ "human.pedestrian.personal_mobility": "Pedestrian",
80
+ "human.pedestrian.police_officer": "Pedestrian",
81
+ "human.pedestrian.construction_worker": "Pedestrian",
82
+ "vehicle.motorcycle": "Cyclist",
83
+ "vehicle.bicycle": "Cyclist",
84
+ # Short names
85
+ "car": "Vehicle", "truck": "Vehicle", "bus": "Vehicle",
86
+ "trailer": "Vehicle", "construction_vehicle": "Vehicle",
87
+ "pedestrian": "Pedestrian",
88
+ "motorcycle": "Cyclist", "bicycle": "Cyclist",
89
+ }
90
+
91
+ # Backwards-compatible aliases
92
+ DETECTION_NAMES = NUSC_DETECTION_NAMES
93
+ CLASS_RANGE = NUSC_CLASS_RANGE
94
+ CATEGORY_MAP = NUSC_CATEGORY_MAP
95
+
96
+
97
+ def map_category(
98
+ raw_category: str,
99
+ category_map: dict[str, str] | None = None,
100
+ class_names: list[str] | None = None,
101
+ ) -> str | None:
102
+ """Map a raw annotation category to a detection class name.
103
+
104
+ Args:
105
+ raw_category: The raw category string from the annotation.
106
+ category_map: Mapping from raw names to detection class names.
107
+ Falls back to ``NUSC_CATEGORY_MAP`` if *None*.
108
+ class_names: Set of valid detection class names.
109
+ If the raw category is already a valid class name, it is
110
+ returned as-is. Falls back to ``NUSC_DETECTION_NAMES``
111
+ if *None*.
112
+
113
+ Returns:
114
+ Detection class name, or *None* if unrecognized.
115
+ """
116
+ if category_map is None:
117
+ category_map = NUSC_CATEGORY_MAP
118
+ if class_names is None:
119
+ class_names = NUSC_DETECTION_NAMES
120
+
121
+ if raw_category in category_map:
122
+ return category_map[raw_category]
123
+ if raw_category in class_names:
124
+ return raw_category
125
+ return None
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class DetectionConfig:
130
+ """Evaluation configuration — fully user-controlled.
131
+
132
+ All fields (class names, distance ranges, category mapping) are
133
+ injected here. Nothing is hardcoded in the evaluator.
134
+ """
135
+ class_names: list[str]
136
+ dist_thresholds: list[float]
137
+ dist_th_tp: float
138
+ min_recall: float
139
+ min_precision: float
140
+ max_boxes_per_sample: int
141
+ class_range: dict[str, float]
142
+ category_map: dict[str, str] = field(default_factory=dict)
143
+ num_recall_points: int = 101
144
+ mean_ap_weight: int = 5
145
+
146
+
147
+ def nusc_detection_config() -> DetectionConfig:
148
+ """Return nuScenes CVPR2019 standard detection evaluation config (10 classes)."""
149
+ return DetectionConfig(
150
+ class_names=list(NUSC_DETECTION_NAMES),
151
+ dist_thresholds=[0.5, 1.0, 2.0, 4.0],
152
+ dist_th_tp=2.0,
153
+ min_recall=0.1,
154
+ min_precision=0.1,
155
+ max_boxes_per_sample=500,
156
+ class_range=dict(NUSC_CLASS_RANGE),
157
+ category_map=dict(NUSC_CATEGORY_MAP),
158
+ )
159
+
160
+
161
+ def detzero_detection_config() -> DetectionConfig:
162
+ """Return DetZero 3-class detection evaluation config."""
163
+ return DetectionConfig(
164
+ class_names=list(DETZERO_DETECTION_NAMES),
165
+ dist_thresholds=[0.5, 1.0, 2.0, 4.0],
166
+ dist_th_tp=2.0,
167
+ min_recall=0.1,
168
+ min_precision=0.1,
169
+ max_boxes_per_sample=500,
170
+ class_range=dict(DETZERO_CLASS_RANGE),
171
+ category_map=dict(DETZERO_CATEGORY_MAP),
172
+ )
173
+
174
+
175
+ @dataclass
176
+ class DetectionResult:
177
+ mean_ap: float
178
+ nd_score: float
179
+ label_aps: dict[str, dict[str, float]]
180
+ mean_dist_aps: dict[str, float]
181
+ tp_errors: dict[str, float]
182
+ label_tp_errors: dict[str, dict[str, float]]
183
+ tp_scores: dict[str, float]
184
+
185
+
186
+ def _calc_ap(md: MetricData, min_recall: float, min_precision: float) -> float:
187
+ """Calculate Average Precision from MetricData."""
188
+ prec = np.copy(md.precision)
189
+ prec = prec[round(100 * min_recall) + 1:]
190
+ prec -= min_precision
191
+ prec[prec < 0] = 0
192
+ if len(prec) == 0:
193
+ return 0.0
194
+ return float(np.mean(prec) / (1.0 - min_precision))
195
+
196
+
197
+ def _calc_tp(md: MetricData, min_recall: float, metric_name: str) -> float:
198
+ """Calculate mean TP metric between min_recall and max_recall."""
199
+ first_ind = round(100 * min_recall) + 1
200
+ last_ind = md.max_recall_ind
201
+ if last_ind < first_ind:
202
+ return 1.0
203
+ metric_arr = getattr(md, metric_name)
204
+ return float(np.nanmean(metric_arr[first_ind:last_ind + 1]))
205
+
206
+
207
+ def _extract_boxes(
208
+ clip: ClipLoader,
209
+ config: DetectionConfig,
210
+ max_boxes: int | None = None,
211
+ ) -> dict[str, list[dict]]:
212
+ """Extract annotation boxes grouped by sample_token, with distance filtering."""
213
+ ep_by_token = {ep.token: ep for ep in clip.ego_poses}
214
+ category_map = config.category_map or {}
215
+ class_names = config.class_names
216
+ class_range = config.class_range
217
+
218
+ # Map sample_token → ego position (for distance filtering)
219
+ sample_ego_pos: dict[str, np.ndarray] = {}
220
+ for sd in clip.sample_data:
221
+ if sd.sample_token not in sample_ego_pos and sd.is_keyframe:
222
+ ep = ep_by_token.get(sd.ego_pose_token)
223
+ if ep is not None:
224
+ sample_ego_pos[sd.sample_token] = ep.translation
225
+
226
+ result: dict[str, list[dict]] = {}
227
+ for sample in clip.samples:
228
+ ego_pos = sample_ego_pos.get(sample.token)
229
+ anns = clip.annotations_for_sample(sample.token)
230
+ boxes = []
231
+ for ann in anns:
232
+ det_name = map_category(ann.category, category_map, class_names)
233
+ if det_name is None or det_name not in class_range:
234
+ continue
235
+ if ego_pos is not None:
236
+ dist = float(np.linalg.norm(ann.translation[:2] - ego_pos[:2]))
237
+ if dist > class_range[det_name]:
238
+ continue
239
+ boxes.append({
240
+ "translation": ann.translation,
241
+ "size": ann.size,
242
+ "rotation": ann.rotation,
243
+ "category": det_name,
244
+ "score": ann.score,
245
+ "velocity": ann.velocity,
246
+ "attributes": ann.attributes,
247
+ })
248
+
249
+ if max_boxes is not None and len(boxes) > max_boxes:
250
+ boxes.sort(key=lambda b: b["score"] or 0, reverse=True)
251
+ boxes = boxes[:max_boxes]
252
+
253
+ result[sample.token] = boxes
254
+
255
+ return result
256
+
257
+
258
+ class DetectionEval:
259
+ """3D object detection evaluator."""
260
+
261
+ def __init__(
262
+ self,
263
+ gt_clips: list[ClipLoader],
264
+ pred_clips: list[ClipLoader],
265
+ config: DetectionConfig | None = None,
266
+ ) -> None:
267
+ self._gt_clips = gt_clips
268
+ self._pred_clips = pred_clips
269
+ self._config = config or nusc_detection_config()
270
+
271
+ def evaluate(self) -> DetectionResult:
272
+ cfg = self._config
273
+
274
+ gt_by_id = {c.meta.clip_id: c for c in self._gt_clips}
275
+ pred_by_id = {c.meta.clip_id: c for c in self._pred_clips}
276
+
277
+ all_gt: dict[str, list[dict]] = {}
278
+ all_pred: dict[str, list[dict]] = {}
279
+
280
+ for clip_id, gt_clip in gt_by_id.items():
281
+ gt_boxes = _extract_boxes(gt_clip, cfg)
282
+ for st, boxes in gt_boxes.items():
283
+ all_gt[st] = boxes
284
+
285
+ pred_clip = pred_by_id.get(clip_id)
286
+ if pred_clip is not None:
287
+ pred_boxes = _extract_boxes(pred_clip, cfg, max_boxes=cfg.max_boxes_per_sample)
288
+ for st, boxes in pred_boxes.items():
289
+ all_pred[st] = boxes
290
+
291
+ for st in all_gt:
292
+ if st not in all_pred:
293
+ all_pred[st] = []
294
+
295
+ # Accumulate per class per distance threshold
296
+ metric_data: dict[str, dict[str, MetricData]] = {}
297
+ for class_name in cfg.class_names:
298
+ metric_data[class_name] = {}
299
+ for dist_th in cfg.dist_thresholds:
300
+ metric_data[class_name][str(dist_th)] = accumulate(
301
+ all_gt, all_pred, class_name, dist_th
302
+ )
303
+
304
+ # AP per class per threshold
305
+ label_aps: dict[str, dict[str, float]] = {}
306
+ for class_name in cfg.class_names:
307
+ label_aps[class_name] = {}
308
+ for dist_th in cfg.dist_thresholds:
309
+ md = metric_data[class_name][str(dist_th)]
310
+ label_aps[class_name][str(dist_th)] = _calc_ap(md, cfg.min_recall, cfg.min_precision)
311
+
312
+ mean_dist_aps = {
313
+ cn: float(np.mean(list(label_aps[cn].values())))
314
+ for cn in cfg.class_names
315
+ }
316
+
317
+ all_ap_values = [ap for cn_aps in label_aps.values() for ap in cn_aps.values()]
318
+ mean_ap = float(np.mean(all_ap_values)) if all_ap_values else 0.0
319
+
320
+ # TP errors per class at dist_th_tp
321
+ label_tp_errors: dict[str, dict[str, float]] = {}
322
+ for class_name in cfg.class_names:
323
+ md = metric_data[class_name][str(cfg.dist_th_tp)]
324
+ label_tp_errors[class_name] = {}
325
+ for metric_name in TP_METRICS:
326
+ if class_name == "traffic_cone" and metric_name in ("attr_err", "vel_err", "orient_err"):
327
+ label_tp_errors[class_name][metric_name] = float("nan")
328
+ elif class_name == "barrier" and metric_name in ("attr_err", "vel_err"):
329
+ label_tp_errors[class_name][metric_name] = float("nan")
330
+ else:
331
+ label_tp_errors[class_name][metric_name] = _calc_tp(md, cfg.min_recall, metric_name)
332
+
333
+ tp_errors = {}
334
+ for metric_name in TP_METRICS:
335
+ values = [label_tp_errors[cn][metric_name] for cn in cfg.class_names]
336
+ tp_errors[metric_name] = float(np.nanmean(values))
337
+
338
+ tp_scores = {m: max(0.0, 1.0 - tp_errors[m]) for m in TP_METRICS}
339
+
340
+ nd_score = (cfg.mean_ap_weight * mean_ap + sum(tp_scores.values())) / (cfg.mean_ap_weight + len(tp_scores))
341
+
342
+ return DetectionResult(
343
+ mean_ap=mean_ap,
344
+ nd_score=nd_score,
345
+ label_aps=label_aps,
346
+ mean_dist_aps=mean_dist_aps,
347
+ tp_errors=tp_errors,
348
+ label_tp_errors=label_tp_errors,
349
+ tp_scores=tp_scores,
350
+ )
@@ -0,0 +1,184 @@
1
+ # x4d_devkit/eval/matching.py
2
+ """Detection matching: accumulate GT/Pred pairs into precision-recall curves."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass
7
+
8
+ import numpy as np
9
+
10
+ from x4d_devkit.eval.metrics import (
11
+ center_distance,
12
+ scale_iou,
13
+ yaw_diff,
14
+ attr_acc,
15
+ cummean,
16
+ )
17
+
18
+ NELEM = 101 # Number of recall interpolation points
19
+
20
+
21
+ @dataclass
22
+ class MetricData:
23
+ """Interpolated precision, recall, and TP error curves (101 points)."""
24
+
25
+ recall: np.ndarray # (101,)
26
+ precision: np.ndarray # (101,)
27
+ confidence: np.ndarray # (101,)
28
+ trans_err: np.ndarray # (101,)
29
+ scale_err: np.ndarray # (101,)
30
+ orient_err: np.ndarray # (101,)
31
+ vel_err: np.ndarray # (101,)
32
+ attr_err: np.ndarray # (101,)
33
+
34
+ @property
35
+ def max_recall_ind(self) -> int:
36
+ """Index of last non-zero confidence (= max achieved recall point)."""
37
+ non_zero = np.nonzero(self.confidence)[0]
38
+ if len(non_zero) == 0:
39
+ return 0
40
+ return int(non_zero[-1])
41
+
42
+ @classmethod
43
+ def no_predictions(cls) -> MetricData:
44
+ """Return a zero-valued MetricData for when there are no predictions."""
45
+ return cls(
46
+ recall=np.linspace(0, 1, NELEM),
47
+ precision=np.zeros(NELEM),
48
+ confidence=np.zeros(NELEM),
49
+ trans_err=np.ones(NELEM),
50
+ scale_err=np.ones(NELEM),
51
+ orient_err=np.ones(NELEM),
52
+ vel_err=np.ones(NELEM),
53
+ attr_err=np.ones(NELEM),
54
+ )
55
+
56
+
57
+ def accumulate(
58
+ gt_boxes: dict[str, list[dict]],
59
+ pred_boxes: dict[str, list[dict]],
60
+ class_name: str,
61
+ dist_th: float,
62
+ ) -> MetricData:
63
+ """Match predictions to ground truth and compute interpolated curves.
64
+
65
+ Args:
66
+ gt_boxes: {sample_token: [box_dict, ...]} — all GT boxes across samples.
67
+ pred_boxes: {sample_token: [box_dict, ...]} — all pred boxes across samples.
68
+ class_name: Class to evaluate (e.g. "car").
69
+ dist_th: Center distance threshold for matching.
70
+
71
+ Each box_dict has: translation (3,), size (3,), rotation (4,) [qx,qy,qz,qw],
72
+ category (str), score (float, pred only), velocity (3,) or None, attributes (list[str]).
73
+
74
+ Returns:
75
+ MetricData with interpolated curves at 101 recall points.
76
+ """
77
+ # Filter by class
78
+ gt_by_sample: dict[str, list[dict]] = {}
79
+ npos = 0
80
+ for sample_token, boxes in gt_boxes.items():
81
+ class_boxes = [b for b in boxes if b["category"] == class_name]
82
+ gt_by_sample[sample_token] = class_boxes
83
+ npos += len(class_boxes)
84
+
85
+ pred_by_sample: dict[str, list[dict]] = {}
86
+ all_preds = []
87
+ for sample_token, boxes in pred_boxes.items():
88
+ class_boxes = [b for b in boxes if b["category"] == class_name]
89
+ class_boxes.sort(key=lambda b: b["score"], reverse=True)
90
+ pred_by_sample[sample_token] = class_boxes
91
+ for i, b in enumerate(class_boxes):
92
+ all_preds.append((b["score"], sample_token, i))
93
+
94
+ if npos == 0:
95
+ return MetricData.no_predictions()
96
+
97
+ # Sort all predictions globally by score descending
98
+ all_preds.sort(key=lambda x: x[0], reverse=True)
99
+
100
+ # Greedy matching
101
+ tp_list = []
102
+ conf_list = []
103
+ match_data = {k: [] for k in ["trans_err", "scale_err", "orient_err", "vel_err", "attr_err"]}
104
+ matched_gt: dict[str, set[int]] = {st: set() for st in gt_by_sample}
105
+
106
+ for score, sample_token, pred_idx in all_preds:
107
+ pred = pred_by_sample[sample_token][pred_idx]
108
+ gt_list = gt_by_sample.get(sample_token, [])
109
+
110
+ min_dist = float("inf")
111
+ min_gt_idx = -1
112
+ for gt_idx, gt in enumerate(gt_list):
113
+ if gt_idx in matched_gt.get(sample_token, set()):
114
+ continue
115
+ d = center_distance(pred["translation"], gt["translation"])
116
+ if d < min_dist:
117
+ min_dist = d
118
+ min_gt_idx = gt_idx
119
+
120
+ is_tp = min_dist < dist_th and min_gt_idx >= 0
121
+ tp_list.append(1 if is_tp else 0)
122
+ conf_list.append(score)
123
+
124
+ if is_tp:
125
+ matched_gt[sample_token].add(min_gt_idx)
126
+ gt = gt_list[min_gt_idx]
127
+ match_data["trans_err"].append(center_distance(pred["translation"], gt["translation"]))
128
+ match_data["scale_err"].append(1.0 - scale_iou(pred["size"], gt["size"]))
129
+ period = np.pi if class_name == "barrier" else 2 * np.pi
130
+ match_data["orient_err"].append(yaw_diff(pred["rotation"], gt["rotation"], period=period))
131
+ if pred["velocity"] is not None and gt["velocity"] is not None:
132
+ vel_diff = float(np.linalg.norm(
133
+ np.asarray(pred["velocity"])[:2] - np.asarray(gt["velocity"])[:2]
134
+ ))
135
+ match_data["vel_err"].append(vel_diff)
136
+ else:
137
+ match_data["vel_err"].append(float("nan"))
138
+ match_data["attr_err"].append(1.0 - attr_acc(gt["attributes"], pred["attributes"]))
139
+ else:
140
+ for key in match_data:
141
+ match_data[key].append(float("nan"))
142
+
143
+ # Compute precision / recall
144
+ tp_arr = np.array(tp_list, dtype=float)
145
+ conf_arr = np.array(conf_list, dtype=float)
146
+ tp_cum = np.cumsum(tp_arr)
147
+ fp_cum = np.cumsum(1 - tp_arr)
148
+ precision_raw = tp_cum / (tp_cum + fp_cum)
149
+ recall_raw = tp_cum / npos
150
+
151
+ # Interpolate to NELEM recall points
152
+ recall_interp = np.linspace(0, 1, NELEM)
153
+ precision_interp = np.zeros(NELEM)
154
+ confidence_interp = np.zeros(NELEM)
155
+
156
+ for i, r in enumerate(recall_interp):
157
+ mask = recall_raw >= r - 1e-10
158
+ if mask.any():
159
+ precision_interp[i] = precision_raw[mask].max()
160
+ confidence_interp[i] = conf_arr[mask].min()
161
+
162
+ # Interpolate TP errors using cumulative mean
163
+ tp_metric_interp = {}
164
+ for key in match_data:
165
+ raw = np.array(match_data[key], dtype=float)
166
+ cm = cummean(raw)
167
+ interp = np.ones(NELEM)
168
+ for i, r in enumerate(recall_interp):
169
+ mask = recall_raw >= r - 1e-10
170
+ if mask.any():
171
+ idx = np.where(mask)[0][0]
172
+ interp[i] = cm[idx]
173
+ tp_metric_interp[key] = interp
174
+
175
+ return MetricData(
176
+ recall=recall_interp,
177
+ precision=precision_interp,
178
+ confidence=confidence_interp,
179
+ trans_err=tp_metric_interp["trans_err"],
180
+ scale_err=tp_metric_interp["scale_err"],
181
+ orient_err=tp_metric_interp["orient_err"],
182
+ vel_err=tp_metric_interp["vel_err"],
183
+ attr_err=tp_metric_interp["attr_err"],
184
+ )
@@ -0,0 +1,83 @@
1
+ """Pure metric functions for detection evaluation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+
7
+ from x4d_devkit.core.transform import quat_to_rotation_matrix
8
+
9
+
10
+ def center_distance(
11
+ translation_a: list[float] | np.ndarray,
12
+ translation_b: list[float] | np.ndarray,
13
+ ) -> float:
14
+ """L2 distance between two 3D points in the XY plane (Z ignored)."""
15
+ a = np.asarray(translation_a, dtype=float)
16
+ b = np.asarray(translation_b, dtype=float)
17
+ return float(np.linalg.norm(a[:2] - b[:2]))
18
+
19
+
20
+ def scale_iou(
21
+ size_a: list[float] | np.ndarray,
22
+ size_b: list[float] | np.ndarray,
23
+ ) -> float:
24
+ """Axis-aligned scale IoU between two 3D box sizes [l, w, h]."""
25
+ sa = np.asarray(size_a, dtype=float)
26
+ sb = np.asarray(size_b, dtype=float)
27
+ min_s = np.minimum(sa, sb)
28
+ intersection = float(np.prod(min_s))
29
+ vol_a = float(np.prod(sa))
30
+ vol_b = float(np.prod(sb))
31
+ union = vol_a + vol_b - intersection
32
+ if union <= 0:
33
+ return 0.0
34
+ return intersection / union
35
+
36
+
37
+ def quaternion_yaw(quat: list[float] | np.ndarray) -> float:
38
+ """Extract yaw angle from quaternion [qx, qy, qz, qw]."""
39
+ qx, qy, qz, qw = quat[0], quat[1], quat[2], quat[3]
40
+ R = quat_to_rotation_matrix(qx, qy, qz, qw)
41
+ v = R @ np.array([1.0, 0.0, 0.0])
42
+ return float(np.arctan2(v[1], v[0]))
43
+
44
+
45
+ def _angle_diff(x: float, y: float, period: float) -> float:
46
+ """Smallest signed angle difference, respecting periodicity."""
47
+ diff = (x - y + period / 2) % period - period / 2
48
+ if diff > np.pi:
49
+ diff -= period
50
+ return diff
51
+
52
+
53
+ def yaw_diff(
54
+ quat_a: list[float] | np.ndarray,
55
+ quat_b: list[float] | np.ndarray,
56
+ period: float = 2 * np.pi,
57
+ ) -> float:
58
+ """Absolute yaw angle difference between two quaternions [qx,qy,qz,qw]."""
59
+ yaw_a = quaternion_yaw(quat_a)
60
+ yaw_b = quaternion_yaw(quat_b)
61
+ return abs(_angle_diff(yaw_a, yaw_b, period))
62
+
63
+
64
+ def attr_acc(
65
+ gt_attrs: list[str],
66
+ pred_attrs: list[str],
67
+ ) -> float:
68
+ """Attribute classification accuracy. Returns NaN if either is empty."""
69
+ if not gt_attrs or not pred_attrs:
70
+ return float("nan")
71
+ return 1.0 if gt_attrs[0] == pred_attrs[0] else 0.0
72
+
73
+
74
+ def cummean(arr: np.ndarray) -> np.ndarray:
75
+ """NaN-aware cumulative mean. Returns ones if all NaN."""
76
+ mask = ~np.isnan(arr)
77
+ count = np.cumsum(mask).astype(float)
78
+ count[count == 0] = 1.0
79
+ filled = np.where(mask, arr, 0.0)
80
+ result = np.cumsum(filled) / count
81
+ if not mask.any():
82
+ return np.ones_like(arr)
83
+ return result
@@ -0,0 +1,4 @@
1
+ from x4d_devkit.validation.validator import validate_clip
2
+ from x4d_devkit.validation.report import ValidationReport
3
+
4
+ __all__ = ["validate_clip", "ValidationReport"]
@@ -0,0 +1,46 @@
1
+ """Validation report dataclass."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+
6
+
7
+ @dataclass
8
+ class ValidationIssue:
9
+ """A single validation issue."""
10
+
11
+ check: str
12
+ level: str # "error" or "warning"
13
+ message: str
14
+ detail: str = ""
15
+
16
+
17
+ @dataclass
18
+ class ValidationReport:
19
+ """Result of validating a clip directory."""
20
+
21
+ clip_id: str
22
+ issues: list[ValidationIssue] = field(default_factory=list)
23
+
24
+ @property
25
+ def ok(self) -> bool:
26
+ return not any(i.level == "error" for i in self.issues)
27
+
28
+ @property
29
+ def errors(self) -> list[ValidationIssue]:
30
+ return [i for i in self.issues if i.level == "error"]
31
+
32
+ @property
33
+ def warnings(self) -> list[ValidationIssue]:
34
+ return [i for i in self.issues if i.level == "warning"]
35
+
36
+ def __str__(self) -> str:
37
+ status = "PASS" if self.ok else "FAIL"
38
+ lines = [
39
+ f"[{status}] {self.clip_id}: {len(self.errors)} errors, {len(self.warnings)} warnings"
40
+ ]
41
+ for issue in self.issues:
42
+ prefix = "ERROR" if issue.level == "error" else "WARN"
43
+ lines.append(f" [{prefix}] {issue.check}: {issue.message}")
44
+ if issue.detail:
45
+ lines.append(f" {issue.detail}")
46
+ return "\n".join(lines)