File size: 16,539 Bytes
729628a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fb23187
 
 
 
 
 
 
 
 
 
729628a
 
 
 
 
 
 
 
 
 
fb23187
 
 
729628a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fb23187
729628a
 
 
 
 
fb23187
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
"""Grader disagreement subsystem.

Grader sources (spec §11): auto label, vendor GT, historical model, VLM label,
human reviewer (when present), independent detector. Computes classification,
spatial, temporal and statistical agreement, and a canonical
grader_consensus_score while preserving the components.

Statistical agreement — used correctly, not interchangeably:
- Cohen's Kappa:        exactly 2 graders, categorical labels.
- Fleiss' Kappa:        >2 graders, every grader rates every (fixed) item.
- Krippendorff's Alpha: any number of graders, tolerates missing ratings.
"""

from __future__ import annotations

import math
from collections import Counter
from typing import Dict, List, Optional, Sequence, Tuple

import numpy as np

from sensorflow.evaluation.records import (
    Annotation,
    EvalStore,
    Frame,
    GraderComparison,
)
from sensorflow.metrics.perception_3d import bev_iou

GRADER_SOURCES = ["auto_label", "vendor_gt", "historical_model", "vlm_label", "independent_detector"]

CLASS_LIST = ["pedestrian", "cyclist", "vehicle", "motorcycle", "truck"]


# ------------------------------------------------------------------ statistics


def cohens_kappa(a: Sequence[str], b: Sequence[str]) -> float:
    """Cohen's kappa for exactly two graders over the same items."""
    assert len(a) == len(b) and len(a) > 0
    cats = sorted(set(a) | set(b))
    n = len(a)
    po = sum(1 for x, y in zip(a, b) if x == y) / n
    pa = Counter(a)
    pb = Counter(b)
    pe = sum((pa[c] / n) * (pb[c] / n) for c in cats)
    if math.isclose(pe, 1.0):
        return 1.0
    return (po - pe) / (1 - pe)


def fleiss_kappa(ratings: List[Dict[str, int]]) -> float:
    """Fleiss' kappa: ratings[i] = {category: count} with a fixed rater count
    per item (every grader rates every item)."""
    if not ratings:
        return 0.0
    n_raters = sum(ratings[0].values())
    cats = sorted({c for r in ratings for c in r})
    N = len(ratings)
    p_j = {c: sum(r.get(c, 0) for r in ratings) / (N * n_raters) for c in cats}
    P_i = []
    for r in ratings:
        s = sum(cnt * (cnt - 1) for cnt in r.values())
        P_i.append(s / (n_raters * (n_raters - 1)) if n_raters > 1 else 1.0)
    P_bar = float(np.mean(P_i))
    P_e = sum(p ** 2 for p in p_j.values())
    if math.isclose(P_e, 1.0):
        return 1.0
    return (P_bar - P_e) / (1 - P_e)


def krippendorff_alpha(data: List[List[Optional[str]]]) -> float:
    """Krippendorff's alpha (nominal) tolerating missing ratings.

    data[rater][item] = category or None.
    """
    # Collect pairable values per item (unit).
    units: List[List[str]] = []
    n_items = max((len(r) for r in data), default=0)
    for i in range(n_items):
        vals = [r[i] for r in data if i < len(r) and r[i] is not None]
        if len(vals) >= 2:
            units.append(vals)
    if not units:
        return 0.0
    # Observed disagreement.
    Do_num, Do_den = 0.0, 0.0
    all_vals: List[str] = []
    for vals in units:
        m = len(vals)
        all_vals.extend(vals)
        cnt = Counter(vals)
        pairs_disagree = 0
        for c1 in cnt:
            for c2 in cnt:
                if c1 != c2:
                    pairs_disagree += cnt[c1] * cnt[c2]
        Do_num += pairs_disagree / (m - 1)
        Do_den += m
    Do = Do_num / Do_den if Do_den else 0.0
    # Expected disagreement.
    total = len(all_vals)
    cnt_all = Counter(all_vals)
    De_num = 0.0
    for c1 in cnt_all:
        for c2 in cnt_all:
            if c1 != c2:
                De_num += cnt_all[c1] * cnt_all[c2]
    De = De_num / (total * (total - 1)) if total > 1 else 0.0
    if math.isclose(De, 0.0):
        return 1.0
    return 1 - Do / De


# ------------------------------------------------------------------ simulated graders

def _simulate_grader_votes(ann: Annotation, gt_class: Optional[str], rng: np.random.Generator) -> Dict[str, str]:
    """Deterministic per-annotation grader simulation.

    Each grader source produces a class vote. Injected GRADER_DISAGREEMENT
    forces the VLM + independent detector to disagree.
    """
    base = gt_class or ann.class_name
    votes: Dict[str, str] = {"auto_label": ann.class_name}
    votes["vendor_gt"] = gt_class if gt_class else ann.class_name

    def maybe_flip(cls: str, p: float) -> str:
        if rng.random() < p:
            alts = [c for c in CLASS_LIST if c != cls]
            return alts[int(rng.integers(0, len(alts)))]
        return cls

    disagree = "GRADER_DISAGREEMENT" in ann.injected_errors
    votes["historical_model"] = maybe_flip(base, 0.02)
    votes["vlm_label"] = maybe_flip(base, 1.0 if disagree else 0.04)
    votes["independent_detector"] = maybe_flip(base, 1.0 if disagree else 0.03)
    return votes


def _spatial_agreement(ann: Annotation, gt_box: Optional[List[float]], rng: np.random.Generator) -> Tuple[float, Dict[str, float]]:
    """Spatial agreement between graders' boxes (auto vs reference vs an
    independent perturbation): IoU, center distance, dims, orientation."""
    if not ann.bbox_3d:
        return 0.0, {}
    ref = gt_box if gt_box else ann.bbox_3d
    iou = bev_iou(ann.bbox_3d, ref)
    center_dist = math.hypot(ann.bbox_3d[0] - ref[0], ann.bbox_3d[1] - ref[1])
    dim_diff = float(np.mean([abs(ann.bbox_3d[i] - ref[i]) / max(ref[i], 0.1) for i in (3, 4, 5)]))
    yaw_diff = abs(ann.bbox_3d[6] - ref[6]) % (2 * math.pi)
    yaw_diff = min(yaw_diff, 2 * math.pi - yaw_diff)
    detail = {
        "iou": round(iou, 4),
        "center_distance": round(center_dist, 3),
        "dimension_diff": round(dim_diff, 4),
        "orientation_diff_deg": round(math.degrees(yaw_diff), 2),
    }
    score = float(np.clip(
        0.5 * iou + 0.25 * max(0, 1 - center_dist / 2.0) + 0.15 * max(0, 1 - dim_diff) + 0.10 * max(0, 1 - yaw_diff / math.pi),
        0, 1))
    return score, detail


def grade_annotation(
    store: EvalStore,
    ann: Annotation,
    frame: Frame,
    temporal_quality: Optional[float] = None,
    seed: int = 7,
) -> GraderComparison:
    rng = np.random.default_rng(abs(hash((ann.annotation_id, seed))) % (2 ** 32))
    gt = next((g for g in frame.gt_boxes if g.gt_id == ann.matched_gt_id), None)

    votes = _simulate_grader_votes(ann, gt.class_name if gt else None, rng)
    classes = list(votes.values())
    # Reliability-weighted class agreement: authoritative sources count more.
    weights = {"auto_label": 1.0, "vendor_gt": 2.0, "historical_model": 0.75,
               "vlm_label": 0.5, "independent_detector": 0.75}
    weight_by_class: Dict[str, float] = {}
    for source, cls in votes.items():
        weight_by_class[cls] = weight_by_class.get(cls, 0.0) + weights.get(source, 1.0)
    total_weight = sum(weights.get(s, 1.0) for s in votes)
    class_agreement = max(weight_by_class.values()) / total_weight

    spatial, spatial_detail = _spatial_agreement(ann, gt.bbox_3d if gt else None, rng)
    temporal = temporal_quality if temporal_quality is not None else (1.0 if ann.track_id else 0.5)

    # Per-item agreement indicators; proper kappa/alpha statistics are computed
    # at dataset level in dataset_grader_statistics (kappa is undefined on a
    # single item).
    kappa_stats: Dict[str, float] = {
        "auto_vs_vendor_agree": 1.0 if votes["auto_label"] == votes["vendor_gt"] else 0.0,
        "vote_agreement": round(class_agreement, 4),
    }

    disagreements: List[str] = []
    if class_agreement < 1.0:
        disagreements.append("classification")
    if spatial < 0.6:
        disagreements.append("spatial")
    if temporal < 0.6:
        disagreements.append("temporal")

    consensus = float(np.clip(0.5 * class_agreement + 0.35 * spatial + 0.15 * temporal, 0, 1))

    # --- additive consensus evidence (canonical consensus above is unchanged)
    csv = consensus_score_vector(votes, spatial_detail, float(temporal), weights)
    candidates = _grader_box_candidates(ann, gt, votes, rng)
    mbr = mbr_consensus_select(candidates)
    extra_stats: Dict[str, float] = {f"csv_{k}": v for k, v in csv.items()}
    for c in mbr["candidates"]:
        extra_stats[f"mbr_risk_{c['source']}"] = c["expected_risk"]
    extra_stats["mbr_selected_idx"] = float(mbr["selected_index"])
    extra_stats["mbr_selected_risk"] = mbr["selected"]["expected_risk"]

    cmp = GraderComparison(
        annotation_id=ann.annotation_id,
        grader_count=len(votes),
        graders=list(votes.keys()),
        class_votes=votes,
        class_agreement=round(class_agreement, 4),
        spatial_agreement=round(spatial, 4),
        temporal_agreement=round(float(temporal), 4),
        consensus=round(consensus, 4),
        disagreement_types=disagreements,
        kappa_stats={**kappa_stats,
                     **{f"spatial_{k}": v for k, v in spatial_detail.items()},
                     **extra_stats},
    )
    store.put("grader_comparisons", cmp)
    return cmp


def dataset_grader_statistics(store: EvalStore, dataset_id: str) -> Dict[str, float]:
    """Dataset-level statistical agreement across all graded items."""
    anns = store.where("annotations", dataset_id=dataset_id)
    comparisons = [store.get("grader_comparisons", a.annotation_id) for a in anns]
    comparisons = [c for c in comparisons if c is not None]
    if not comparisons:
        return {}

    # Two-grader Cohen's kappa: auto label vs vendor GT over all items.
    auto = [c.class_votes.get("auto_label", "") for c in comparisons]
    vendor = [c.class_votes.get("vendor_gt", "") for c in comparisons]
    cohen = cohens_kappa(auto, vendor)

    # Fleiss' kappa: all graders rate all items (fixed panel).
    ratings = [dict(Counter(c.class_votes.values())) for c in comparisons]
    fleiss = fleiss_kappa(ratings)

    # Krippendorff's alpha: tolerate a missing vendor rating (simulated missing
    # data on unmatched labels).
    raters: List[List[Optional[str]]] = [
        [c.class_votes.get(source) for c in comparisons] for source in GRADER_SOURCES
    ]
    # Mark vendor rating missing where the label had no matched GT.
    ann_by_id = {a.annotation_id: a for a in anns}
    for j, c in enumerate(comparisons):
        a = ann_by_id.get(c.annotation_id)
        if a is not None and a.matched_gt_id is None:
            raters[GRADER_SOURCES.index("vendor_gt")][j] = None
    kripp = krippendorff_alpha(raters)

    stats = {
        "cohen_kappa": round(cohen, 4),
        "fleiss_kappa": round(fleiss, 4),
        "krippendorff_alpha": round(kripp, 4),
        "mean_consensus": round(float(np.mean([c.consensus for c in comparisons if c.consensus is not None])), 4),
    }

    # Additive: Kendall's tau between the model-confidence ranking of items and
    # the grader-consensus ranking (do graders order item quality like the
    # model's own confidence does?). Needs >= 2 items.
    paired = [(ann_by_id[c.annotation_id].confidence, c.consensus or 0.0)
              for c in comparisons if c.annotation_id in ann_by_id]
    conf = [p[0] for p in paired]
    cons = [p[1] for p in paired]
    if len(conf) >= 2:
        tau, p = kendalls_tau(conf, cons)
        stats["kendall_tau_confidence_vs_consensus"] = tau
        stats["kendall_tau_p_value"] = p
    return stats


# ------------------------------------------------------------------ additive consensus extensions
#
# The canonical scalar consensus above stays authoritative. The functions
# below add richer evidence (spec: consensus score vector, Kendall's tau
# ranking agreement, Minimum Bayes Risk selection) surfaced through
# GraderComparison.kappa_stats with csv_* / mbr_* prefixes.


def consensus_score_vector(votes: Dict[str, str], spatial_detail: Dict[str, float],
                           temporal: float,
                           weights: Optional[Dict[str, float]] = None) -> Dict[str, float]:
    """Per-element consensus scores instead of one scalar.

    Elements: majority class share, reliability-weighted class agreement,
    spatial sub-scores (IoU, center, dimensions, orientation) and temporal
    consistency — each individually in [0, 1].
    """
    n = max(len(votes), 1)
    counts = Counter(votes.values())
    majority_share = max(counts.values()) / n if counts else 0.0
    weights = weights or {}
    weight_by_class: Dict[str, float] = {}
    for source, cls in votes.items():
        weight_by_class[cls] = weight_by_class.get(cls, 0.0) + weights.get(source, 1.0)
    total_w = sum(weights.get(s, 1.0) for s in votes) or 1.0
    weighted = max(weight_by_class.values()) / total_w if weight_by_class else 0.0

    center = spatial_detail.get("center_distance")
    dims = spatial_detail.get("dimension_diff")
    orient = spatial_detail.get("orientation_diff_deg")
    return {
        "class_majority_share": round(majority_share, 4),
        "class_weighted_agreement": round(weighted, 4),
        "spatial_iou": round(float(spatial_detail.get("iou", 0.0)), 4),
        "spatial_center": round(max(0.0, 1 - (center or 2.0) / 2.0), 4),
        "spatial_dims": round(max(0.0, 1 - (dims if dims is not None else 1.0)), 4),
        "spatial_orientation": round(max(0.0, 1 - (orient if orient is not None else 180.0) / 180.0), 4),
        "temporal": round(float(temporal), 4),
    }


def kendalls_tau(scores_a: Sequence[float], scores_b: Sequence[float]) -> Tuple[float, float]:
    """Kendall's tau-b ranking agreement between two score lists over the same
    items (scipy.stats.kendalltau). Returns (tau, p_value)."""
    from scipy.stats import kendalltau
    tau, p = kendalltau(list(scores_a), list(scores_b))
    if math.isnan(tau):
        return 0.0, 1.0
    return round(float(tau), 4), round(float(p), 6)


def mbr_utility(cand_a: Dict, cand_b: Dict, iou_weight: float = 0.5) -> float:
    """Pairwise utility for MBR: class match + BEV IoU (each in [0, 1])."""
    class_match = 1.0 if cand_a.get("class_name") == cand_b.get("class_name") else 0.0
    box_a, box_b = cand_a.get("bbox_3d"), cand_b.get("bbox_3d")
    iou = bev_iou(box_a, box_b) if box_a and box_b else 0.0
    return (1 - iou_weight) * class_match + iou_weight * iou


def mbr_consensus_select(candidates: List[Dict], iou_weight: float = 0.5) -> Dict:
    """Minimum Bayes Risk consensus selection.

    Among candidate labels (each {source, class_name, bbox_3d}), select the one
    minimizing expected disagreement risk against the others treated as
    pseudo-references: risk(c) = 1 - mean_utility(c, others).
    """
    if not candidates:
        raise ValueError("mbr_consensus_select requires at least one candidate")
    scored = []
    for i, cand in enumerate(candidates):
        others = [c for j, c in enumerate(candidates) if j != i]
        util = (float(np.mean([mbr_utility(cand, o, iou_weight) for o in others]))
                if others else 1.0)
        scored.append({"source": cand.get("source", f"candidate_{i}"),
                       "class_name": cand.get("class_name"),
                       "expected_utility": round(util, 4),
                       "expected_risk": round(1 - util, 4)})
    best = min(range(len(scored)),
               key=lambda i: (scored[i]["expected_risk"], i))
    return {"selected_index": best, "selected": scored[best], "candidates": scored,
            "utility": f"{1 - iou_weight:.2f}*class_match + {iou_weight:.2f}*bev_iou"}


def _grader_box_candidates(ann: Annotation, gt, votes: Dict[str, str],
                           rng: np.random.Generator) -> List[Dict]:
    """Candidate labels for MBR selection. auto_label and vendor_gt use their
    real boxes; the remaining graders' boxes are SIMULATED as deterministic
    small perturbations of their vote's base box (consistent with the simulated
    grader panel above)."""
    base_box = list(gt.bbox_3d) if gt is not None else (list(ann.bbox_3d) if ann.bbox_3d else None)
    out: List[Dict] = []
    for source in GRADER_SOURCES:
        if source not in votes:
            continue
        if source == "auto_label":
            box = list(ann.bbox_3d) if ann.bbox_3d else None
        elif source == "vendor_gt":
            box = list(gt.bbox_3d) if gt is not None else (list(ann.bbox_3d) if ann.bbox_3d else None)
        elif base_box is not None:
            jitter = rng.normal(0.0, 0.12, size=3)
            box = list(base_box)
            box[0] += float(jitter[0])
            box[1] += float(jitter[1])
            box[6] += float(jitter[2] * 0.2)
        else:
            box = None
        out.append({"source": source, "class_name": votes[source], "bbox_3d": box})
    return out