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