alami-vision-api / ml /scripts /eval_scene_gate.py
alami-ci
Deploy from alami-eco/alami-trash-ai@aee69796b70947e95efdb9c7483fa52f8d3b4520
76838d6
Raw
History Blame Contribute Delete
5.7 kB
#!/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())