File size: 5,700 Bytes
76838d6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
eval_scene_gate.py — Offline-Messung des Phase-A-Szenen-Gates (Stufe A0).

Misst die zwei Zahlen, die über die Live-Schaltung entscheiden
(docs/PHASE_A_SCENE_GATE.md §5):

  * FP-Kill-Rate  – Anteil der NICHT-Müll-Bilder (Natur/leerer Boden/Tier/Möbel),
                    bei denen das Gate korrekt KEIN Müll meldet. Hoch = gut.
  * TP-Retention  – Anteil der ECHTEN Müll-Bilder, die das Gate BEHÄLT
                    (nicht fälschlich unterdrückt). Muss ~1.0 bleiben — ein
                    False Negative = ehrlicher Nutzer bekommt 0 TC.

Eine Szene gilt bei Schwelle τ als "Müll", wenn waste_likelihood >= τ.
Wir sweepen τ und suchen den Betriebspunkt, der das Gate-Kriterium erfüllt
(Default: FP-Kill >= 0.60 BEI TP-Retention >= 0.99).

Nutzung:
    python -m ml.scripts.eval_scene_gate \
        --positives data/scene_eval/waste \
        --negatives data/scene_eval/non_waste \
        --out artifacts/summary_scene_gate.json

Ergebnis: JSON neben summary_eval.json (gleiche Konvention wie evaluate.py),
fließt als Snapshot in die Chronik.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Dict, List

IMG_EXT = {".jpg", ".jpeg", ".png", ".bmp", ".webp"}


def list_images(d: Path, limit: int = 0) -> List[Path]:
    if not d or not d.exists():
        return []
    imgs = sorted(p for p in d.rglob("*") if p.suffix.lower() in IMG_EXT)
    return imgs[:limit] if limit else imgs


def score_folder(gate, imgs: List[Path]) -> List[float]:
    """waste_likelihood je Bild."""
    out: List[float] = []
    for i, p in enumerate(imgs, 1):
        try:
            scene = gate.analyze(str(p))
            out.append(float(scene["waste_likelihood"]))
        except Exception as e:  # ein kaputtes Bild darf den Lauf nicht killen
            print(f"[WARN] {p.name}: {type(e).__name__}: {str(e)[:120]}", file=sys.stderr)
            out.append(0.0)
        if i % 25 == 0:
            print(f"  … {i}/{len(imgs)}", file=sys.stderr)
    return out


def sweep(pos: List[float], neg: List[float],
          min_fp_kill: float, min_tp_retention: float) -> Dict:
    """τ-Sweep; findet den besten zulässigen Betriebspunkt."""
    thresholds = [round(t / 100, 2) for t in range(0, 101, 2)]
    curve = []
    best = None
    for tau in thresholds:
        tp_ret = sum(1 for s in pos if s >= tau) / len(pos) if pos else None
        fp_kill = sum(1 for s in neg if s < tau) / len(neg) if neg else None
        point = {"tau": tau, "tp_retention": tp_ret, "fp_kill_rate": fp_kill}
        curve.append(point)
        # zulässig = TP-Retention-Schmerzgrenze gehalten; darunter maximiere FP-Kill
        if (tp_ret is not None and fp_kill is not None
                and tp_ret >= min_tp_retention):
            if best is None or fp_kill > best["fp_kill_rate"]:
                best = point
    passed = bool(best is not None and best["fp_kill_rate"] >= min_fp_kill)
    return {"curve": curve, "recommended_operating_point": best, "gate_passed": passed}


def main() -> int:
    ap = argparse.ArgumentParser(description="Phase-A Szenen-Gate — Offline-Eval (A0)")
    ap.add_argument("--positives", type=Path, required=True, help="Ordner mit echten Müll-Bildern")
    ap.add_argument("--negatives", type=Path, required=True, help="Ordner mit Nicht-Müll/leer-Bildern")
    ap.add_argument("--out", type=Path, default=Path("artifacts/summary_scene_gate.json"))
    ap.add_argument("--weights", default="yolov8s-worldv2.pt")
    ap.add_argument("--conf", type=float, default=0.10)
    ap.add_argument("--limit", type=int, default=0, help="max Bilder je Ordner (0=alle)")
    ap.add_argument("--min-fp-kill", type=float, default=0.60)
    ap.add_argument("--min-tp-retention", type=float, default=0.99)
    args = ap.parse_args()

    pos_imgs = list_images(args.positives, args.limit)
    neg_imgs = list_images(args.negatives, args.limit)
    if not pos_imgs or not neg_imgs:
        print(f"[ERROR] Brauche Bilder in beiden Ordnern. "
              f"positives={len(pos_imgs)} negatives={len(neg_imgs)}", file=sys.stderr)
        return 2

    print(f"Positives: {len(pos_imgs)} · Negatives: {len(neg_imgs)} · weights={args.weights}",
          file=sys.stderr)

    # Modell erst hier laden (schwer) — so bleibt --help / Argparse leichtgewichtig.
    from ml.scene_gate import SceneGate, GATE_VERSION
    gate = SceneGate(weights=args.weights, conf_thr=args.conf)

    print("Scoring positives …", file=sys.stderr)
    pos = score_folder(gate, pos_imgs)
    print("Scoring negatives …", file=sys.stderr)
    neg = score_folder(gate, neg_imgs)

    result = sweep(pos, neg, args.min_fp_kill, args.min_tp_retention)
    summary = {
        "gate_version": GATE_VERSION,
        "weights": args.weights,
        "conf_thr": args.conf,
        "n_positives": len(pos_imgs),
        "n_negatives": len(neg_imgs),
        "criterion": {"min_fp_kill": args.min_fp_kill, "min_tp_retention": args.min_tp_retention},
        **result,
    }
    args.out.parent.mkdir(parents=True, exist_ok=True)
    args.out.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")

    op = result["recommended_operating_point"]
    print("\n===== Phase-A Szenen-Gate — Ergebnis =====")
    if op:
        print(f"  Betriebspunkt τ={op['tau']}: "
              f"TP-Retention={op['tp_retention']:.3f} · FP-Kill={op['fp_kill_rate']:.3f}")
    print(f"  Gate-Kriterium erfüllt: {'JA ✅' if result['gate_passed'] else 'NEIN ❌'}")
    print(f"  -> {args.out}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())