"""Evaluate outfit composition *quality* — Scandi fit, contrast axes, color harmony. Complements diversity eval. Where diversity asks "do we vary across anchors", quality asks "is each individual outfit good". Metrics: mean_scandi_score — average outfit-level scandi score over the sample. AC1 floor: 0.55 (recon target — Scandi catalog heavy enough that this should be easy to clear). mean_axes_covered — average count of Bornstein axes the outfit speaks on (any item past ±0.3). AC1 floor: 2.5. color_harmony_rate — fraction of dress→shoes pairs that read harmonious. A pair is "harmonious" if either dominant LAB is neutral OR ΔE2000 < 25. This replaces the raw mean-ΔE metric in the original plan, which penalises the catalog's correct-but- neutral shoe pairings (a black mule with a blue dress = high ΔE but stylistically right). AC7 floor: 0.80. Usage:: uv run --no-project --with numpy --with "scikit-image>=0.22,<0.25" \\ scripts/eval_outfit_quality.py [N=80] """ # /// script # requires-python = ">=3.11" # dependencies = ["numpy", "scikit-image>=0.22,<0.25"] # /// import random import sqlite3 import sys from pathlib import Path from statistics import mean sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from taste.colors import delta_e, is_neutral # noqa: E402 from taste.outfits import _row_dom, compose_outfit # noqa: E402 DB_PATH = Path.home() / ".taste/taste.db" SAMPLE_BUCKETS = ("dress", "top", "bottom", "outerwear") SCANDI_FLOOR = 0.55 AXES_FLOOR = 2.5 HARMONY_FLOOR = 0.80 def main() -> int: n = int(sys.argv[1]) if len(sys.argv) > 1 else 80 rng = random.Random(42) conn = sqlite3.connect(str(DB_PATH)) conn.row_factory = sqlite3.Row placeholders = ",".join("?" * len(SAMPLE_BUCKETS)) candidates = conn.execute( f""" SELECT url FROM products WHERE in_stock = 1 AND score IS NOT NULL AND image_url != '' AND canonical_category IN ({placeholders}) """, SAMPLE_BUCKETS, ).fetchall() if not candidates: print("No anchors found — nothing to evaluate.") return 1 sample = rng.sample(candidates, min(n, len(candidates))) scandi_per: list[float] = [] axes_per: list[int] = [] contrasted_per: list[int] = [] harmonious_pairs = 0 total_dress_shoe_pairs = 0 delta_e_dress_shoes: list[float] = [] # diagnostic only for r in sample: outfit = compose_outfit(r["url"], conn) if not outfit: continue scandi_per.append(outfit["scandi_score"]) axes_per.append(len(outfit["axes_covered"])) contrasted_per.append(len(outfit.get("axes_contrasted", []))) if outfit["anchor_bucket"] != "dress": continue anchor_dom = _row_dom(outfit["anchor"]) for c in outfit["complements"]: if c["bucket"] != "shoes": continue shoes_dom = _row_dom(c["product"]) if anchor_dom is None or shoes_dom is None: continue total_dress_shoe_pairs += 1 de = delta_e(anchor_dom, shoes_dom) delta_e_dress_shoes.append(de) if is_neutral(anchor_dom) or is_neutral(shoes_dom) or de < 25: harmonious_pairs += 1 if not scandi_per: print("No outfits composed.") return 1 n_outfits = len(scandi_per) avg_scandi = mean(scandi_per) avg_axes = mean(axes_per) avg_contrasted = mean(contrasted_per) harmony_rate = ( harmonious_pairs / total_dress_shoe_pairs if total_dress_shoe_pairs else 0.0 ) print(f"Outfits composed: {n_outfits}/{len(sample)}") print(f"Mean Scandi score: {avg_scandi:.3f} (floor {SCANDI_FLOOR:.2f})") print(f"Mean axes covered: {avg_axes:.2f} (floor {AXES_FLOOR:.2f})") print(f"Mean axes contrasted: {avg_contrasted:.2f} (diagnostic)") if total_dress_shoe_pairs: print( f"Color harmony rate: {harmony_rate:.3f} " f"(floor {HARMONY_FLOOR:.2f}, n={total_dress_shoe_pairs})" ) print(f"Mean dress→shoes ΔE2000: {mean(delta_e_dress_shoes):.1f} (diagnostic)") fails: list[str] = [] if avg_scandi < SCANDI_FLOOR: fails.append(f"scandi {avg_scandi:.3f} < {SCANDI_FLOOR}") if avg_axes < AXES_FLOOR: fails.append(f"axes {avg_axes:.2f} < {AXES_FLOOR}") if total_dress_shoe_pairs and harmony_rate < HARMONY_FLOOR: fails.append(f"harmony {harmony_rate:.3f} < {HARMONY_FLOOR}") if fails: print(f"\nFAIL — {'; '.join(fails)}") return 1 print("\nPASS — all quality floors cleared") return 0 if __name__ == "__main__": sys.exit(main())