Spaces:
Running
Running
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())
|