File size: 13,355 Bytes
4d9b003 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 | # SPDX-License-Identifier: Apache-2.0
"""C06 metrics: device-vs-reference agreement measures (host side, numpy only, float64 arithmetic).
Inputs may be numpy arrays, torch tensors (bf16 included) or nested lists. Gates are applied by
:mod:`.golden` (``GateRegistry``); these functions only measure.
========================== =====================================================================================
function use
========================== =====================================================================================
``pcc`` dense Pearson correlation (the default gate metric)
``masked_pcc`` PCC over the elements where a mask is true (valid pillars, in-range points)
``valid_row_pcc`` PCC over the first ``n_valid`` rows of a fixed-capacity buffer
``error_stats`` max / mean abs error, relative L2, PCC in one dict
``argmax_agreement`` fraction of positions whose argmax over ``axis`` agrees (segmentation logits)
``label_agreement`` fraction of equal labels (decided class maps)
``mask_iou`` ``mean_iou`` binary-mask IoU; per-class and mean IoU of label maps
``box_iou_xyxy`` pairwise IoU of 2-D boxes
``topk_overlap`` |A ∩ B| / k of two index sets (data-dependent selections)
``topk_set_overlap`` the same, computed from two score arrays
``match_detections`` greedy same-label matching by BEV-centre distance: recall, precision, errors
``ade_fde`` average / final displacement error of trajectories
========================== =====================================================================================
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
__all__ = [
"as_array",
"pcc",
"masked_pcc",
"valid_row_pcc",
"error_stats",
"argmax_agreement",
"label_agreement",
"mask_iou",
"mean_iou",
"box_iou_xyxy",
"topk_overlap",
"topk_set_overlap",
"DetectionMatch",
"match_detections",
"ade_fde",
]
def as_array(x: Any, dtype: Optional[Any] = None) -> np.ndarray:
"""numpy / torch (any dtype, bf16 widened to float32) / list -> numpy, without importing torch."""
if hasattr(x, "detach") and hasattr(x, "cpu"):
t = x.detach().cpu()
if str(t.dtype) == "torch.bfloat16":
t = t.float()
x = t.numpy()
a = np.asarray(x)
return a if dtype is None else a.astype(dtype, copy=False)
def _pcc_flat(a: np.ndarray, b: np.ndarray) -> float:
if a.shape != b.shape:
raise ValueError(f"shape mismatch: {a.shape} vs {b.shape}")
if a.size == 0:
return float("nan")
if not (np.isfinite(a).all() and np.isfinite(b).all()):
return float("nan")
# A constant side is detected exactly (max == min), never from the centred sum of squares: the mean of a
# constant array is not exact in floating point (np.full(1000, 0.1).mean() != 0.1), so the centred values hold
# rounding noise that would correlate perfectly with the other side's noise (two different constants gave +-1).
const_a, const_b = a.max() == a.min(), b.max() == b.min()
if const_a or const_b: # equal constants agree perfectly, anything else does not
return 1.0 if (const_a and const_b and np.allclose(a, b)) else 0.0
da = a - a.mean()
db = b - b.mean()
sa = float(np.dot(da, da))
sb = float(np.dot(db, db))
return float(np.dot(da, db) / math.sqrt(sa * sb))
def pcc(test: Any, ref: Any) -> float:
"""Pearson correlation of two equally shaped tensors (float64). NaN if any value is non-finite or both are
empty; when a side is constant: 1.0 for two equal constants (``np.allclose``), else 0.0 (two different
constants included)."""
return _pcc_flat(as_array(test, np.float64).reshape(-1), as_array(ref, np.float64).reshape(-1))
def masked_pcc(test: Any, ref: Any, mask: Any) -> float:
"""PCC over ``mask`` (broadcastable to the tensors' shape)."""
a, b = as_array(test, np.float64), as_array(ref, np.float64)
if a.shape != b.shape:
raise ValueError(f"shape mismatch: {a.shape} vs {b.shape}")
m = np.broadcast_to(as_array(mask).astype(bool), a.shape)
return _pcc_flat(a[m], b[m])
def valid_row_pcc(test: Any, ref: Any, n_valid: Optional[int] = None, *, rows: Any = None, axis: int = 0) -> float:
"""PCC over the valid rows of fixed-capacity buffers: the first ``n_valid`` along ``axis``, or the rows where
the boolean vector ``rows`` is true."""
a, b = as_array(test, np.float64), as_array(ref, np.float64)
if (n_valid is None) == (rows is None):
raise ValueError("give exactly one of n_valid and rows")
if rows is None:
idx = np.arange(int(n_valid))
else:
idx = np.flatnonzero(as_array(rows).astype(bool))
return _pcc_flat(np.take(a, idx, axis=axis).reshape(-1), np.take(b, idx, axis=axis).reshape(-1))
def error_stats(test: Any, ref: Any) -> Dict[str, float]:
"""``{"pcc", "max_abs", "mean_abs", "rel_l2", "n"}`` (``rel_l2 = ||t - r|| / ||r||``)."""
a, b = as_array(test, np.float64), as_array(ref, np.float64)
if a.shape != b.shape:
raise ValueError(f"shape mismatch: {a.shape} vs {b.shape}")
d = np.abs(a - b)
ref_norm = float(np.linalg.norm(b))
return {"pcc": _pcc_flat(a.reshape(-1), b.reshape(-1)),
"max_abs": float(d.max()) if d.size else 0.0,
"mean_abs": float(d.mean()) if d.size else 0.0,
"rel_l2": float(np.linalg.norm(a - b) / ref_norm) if ref_norm > 0 else float(np.linalg.norm(a - b)),
"n": int(a.size)}
def argmax_agreement(test_logits: Any, ref_logits: Any, *, axis: int = -1, mask: Any = None) -> float:
"""Fraction of positions where ``argmax`` over ``axis`` agrees (ties resolve to the lowest index, as numpy
and Autoware's decision rules do). ``mask`` selects positions (shape without ``axis``)."""
a = np.argmax(as_array(test_logits), axis=axis)
b = np.argmax(as_array(ref_logits), axis=axis)
return label_agreement(a, b, mask=mask)
def label_agreement(test_labels: Any, ref_labels: Any, *, mask: Any = None) -> float:
"""Fraction of equal labels (optionally over ``mask``). NaN when nothing is selected."""
a, b = as_array(test_labels), as_array(ref_labels)
if a.shape != b.shape:
raise ValueError(f"shape mismatch: {a.shape} vs {b.shape}")
eq = a == b
if mask is not None:
eq = eq[np.broadcast_to(as_array(mask).astype(bool), eq.shape)]
return float(eq.mean()) if eq.size else float("nan")
def mask_iou(test_mask: Any, ref_mask: Any) -> float:
"""IoU of two binary masks (1.0 when both are empty)."""
a, b = as_array(test_mask).astype(bool), as_array(ref_mask).astype(bool)
union = np.logical_or(a, b).sum()
return 1.0 if union == 0 else float(np.logical_and(a, b).sum() / union)
def mean_iou(test_labels: Any, ref_labels: Any, num_classes: int, *,
ignore_index: Optional[int] = None) -> Tuple[float, List[float]]:
"""``(mIoU over classes present in either map, per-class IoU with NaN for absent classes)``."""
a, b = as_array(test_labels).reshape(-1), as_array(ref_labels).reshape(-1)
if a.shape != b.shape:
raise ValueError(f"shape mismatch: {a.shape} vs {b.shape}")
if ignore_index is not None:
keep = b != ignore_index
a, b = a[keep], b[keep]
per = []
for c in range(num_classes):
inter = np.logical_and(a == c, b == c).sum()
union = np.logical_or(a == c, b == c).sum()
per.append(float(inter / union) if union else float("nan"))
valid = [v for v in per if not math.isnan(v)]
return (float(np.mean(valid)) if valid else float("nan")), per
def box_iou_xyxy(boxes_a: Any, boxes_b: Any) -> np.ndarray:
"""Pairwise IoU ``[N, M]`` of axis-aligned boxes ``[x1, y1, x2, y2]`` (float64)."""
a = as_array(boxes_a, np.float64).reshape(-1, 4)
b = as_array(boxes_b, np.float64).reshape(-1, 4)
ix1 = np.maximum(a[:, None, 0], b[None, :, 0])
iy1 = np.maximum(a[:, None, 1], b[None, :, 1])
ix2 = np.minimum(a[:, None, 2], b[None, :, 2])
iy2 = np.minimum(a[:, None, 3], b[None, :, 3])
inter = np.clip(ix2 - ix1, 0, None) * np.clip(iy2 - iy1, 0, None)
area_a = np.clip(a[:, 2] - a[:, 0], 0, None) * np.clip(a[:, 3] - a[:, 1], 0, None)
area_b = np.clip(b[:, 2] - b[:, 0], 0, None) * np.clip(b[:, 3] - b[:, 1], 0, None)
union = area_a[:, None] + area_b[None, :] - inter
with np.errstate(invalid="ignore", divide="ignore"):
return np.where(union > 0, inter / union, 0.0)
def topk_overlap(test_indices: Any, ref_indices: Any, k: Optional[int] = None) -> float:
"""``|set(test[:k]) ∩ set(ref[:k])| / k`` (``k`` defaults to ``len(ref)``)."""
a, b = as_array(test_indices).reshape(-1), as_array(ref_indices).reshape(-1)
k = len(b) if k is None else int(k)
if k <= 0:
return float("nan")
return len(set(a[:k].tolist()) & set(b[:k].tolist())) / k
def topk_set_overlap(test_scores: Any, ref_scores: Any, k: int) -> float:
"""Overlap of the top-``k`` index sets of two score arrays (flattened; stable descending order)."""
a, b = as_array(test_scores, np.float64).reshape(-1), as_array(ref_scores, np.float64).reshape(-1)
ta = np.argsort(-a, kind="stable")[:k]
tb = np.argsort(-b, kind="stable")[:k]
return topk_overlap(ta, tb, k)
@dataclass
class DetectionMatch:
"""Result of :func:`match_detections` (``pairs`` are ``(test_index, ref_index)``)."""
pairs: List[Tuple[int, int]]
n_test: int
n_ref: int
center_errors: List[float] = field(default_factory=list)
score_errors: List[float] = field(default_factory=list)
@property
def matched(self) -> int:
return len(self.pairs)
@property
def recall(self) -> float:
return self.matched / self.n_ref if self.n_ref else 1.0
@property
def precision(self) -> float:
return self.matched / self.n_test if self.n_test else 1.0
def to_dict(self) -> Dict[str, Any]:
def stat(v: List[float], fn) -> Optional[float]:
return float(fn(v)) if v else None
return {"n_test": self.n_test, "n_ref": self.n_ref, "matched": self.matched, "recall": self.recall,
"precision": self.precision, "center_err_mean": stat(self.center_errors, np.mean),
"center_err_max": stat(self.center_errors, np.max), "score_err_max": stat(self.score_errors, np.max),
"score_err_mean": stat(self.score_errors, np.mean)}
def _centers(x: Any, n: int, dims: int) -> np.ndarray:
a = as_array(x, np.float64)
if a.size == 0:
return np.zeros((n, dims))
if a.size % max(n, 1) or n == 0:
raise ValueError(f"{a.shape[0] if a.ndim else 0} centres for {n} labels: lengths differ")
return a.reshape(n, -1)[:, :dims]
def match_detections(test_centers: Any, test_labels: Any, test_scores: Any, ref_centers: Any, ref_labels: Any,
ref_scores: Any, *, max_dist: float = 0.5, dims: int = 2,
same_label: bool = True) -> DetectionMatch:
"""Greedy matching: reference boxes in descending score order each take the nearest unused test box (same
label unless ``same_label=False``) within ``max_dist`` of its centre, measured over the first ``dims``
coordinates (2 = BEV x/y). ``*_centers`` are ``[N, >=dims]`` (box arrays ``[N, 7]`` work as-is). A box with a
non-finite centre never matches (it costs recall / precision; it does not block the other boxes)."""
tl, rl = as_array(test_labels).reshape(-1), as_array(ref_labels).reshape(-1)
ts, rs = as_array(test_scores, np.float64).reshape(-1), as_array(ref_scores, np.float64).reshape(-1)
if len(ts) != len(tl) or len(rs) != len(rl):
raise ValueError(f"labels / scores lengths differ: test {len(tl)}/{len(ts)}, ref {len(rl)}/{len(rs)}")
tc, rc = _centers(test_centers, len(tl), dims), _centers(ref_centers, len(rl), dims)
used = np.zeros(len(tl), bool)
result = DetectionMatch([], len(tl), len(rl))
for i in np.argsort(-rs, kind="stable"):
if not len(tl):
break
d = np.linalg.norm(tc - rc[i], axis=1)
bad = used | ~np.isfinite(d) # np.argmin would pick a NaN distance before any real one
if same_label:
bad |= tl != rl[i]
d[bad] = np.inf
j = int(np.argmin(d))
if d[j] <= max_dist:
used[j] = True
result.pairs.append((j, int(i)))
result.center_errors.append(float(d[j]))
result.score_errors.append(abs(float(ts[j] - rs[i])))
return result
def ade_fde(pred: Any, ref: Any, *, dims: int = 2) -> Tuple[float, float]:
"""Average and final displacement errors of trajectories ``[..., T, >=dims]`` (Euclidean over the first
``dims`` coordinates; averaged over all leading dims)."""
p, r = as_array(pred, np.float64), as_array(ref, np.float64)
if p.shape[:-1] != r.shape[:-1]:
raise ValueError(f"trajectory shapes differ: {p.shape} vs {r.shape}")
disp = np.linalg.norm(p[..., :dims] - r[..., :dims], axis=-1)
return float(disp.mean()), float(disp[..., -1].mean())
|