""" scoring.py — strategic outfit scoring model. Replaces all scoring logic previously inline in app.py. Import compute_score() and recommend_outfits() from here. """ from __future__ import annotations import copy from typing import Any # --------------------------------------------------------------------------- # Weights # --------------------------------------------------------------------------- WEIGHTS: dict[str, float] = { "color": 0.30, "style": 0.25, "occasion": 0.20, "fit": 0.13, "pattern": 0.12, } TOP_K = 6 # --------------------------------------------------------------------------- # Normalisation helpers # --------------------------------------------------------------------------- _BASE_COLORS = [ "black", "white", "grey", "gray", "beige", "cream", "tan", "navy", "blue", "olive", "green", "brown", "maroon", "burgundy", "red", "pink", "purple", "orange", "yellow", "gold", "silver", "khaki", "coral", "teal", "indigo", "lavender", "mustard", ] def _norm(value: Any) -> str: return str(value or "").strip().lower() def extract_base_color(raw: Any) -> str: """'Navy Blue' -> 'navy', 'Olive Green' -> 'olive', etc.""" n = _norm(raw) for base in _BASE_COLORS: if base in n: return base return n def extract_style(item: dict[str, Any]) -> str: """Classifier writes 'occasion'; normaliser copies to 'style'. Accept both.""" raw = _norm(item.get("style") or item.get("occasion") or "") if raw in {"work", "business", "office"}: return "formal" if raw in {"party", "festive", "ethnic"}: return "party" if raw in {"sports", "sport", "gym", "active"}: return "sports" if raw in {"casual", "formal", "streetwear", "party", "sports"}: return raw return "casual" # safe default def extract_fit(item: dict[str, Any]) -> str: n = _norm(item.get("fit") or "") if "slim" in n or "fitted" in n: return "slim" if "over" in n or "baggy" in n or "loose" in n: return "oversized" if "regular" in n or "relaxed" in n: return "regular" return "regular" def extract_pattern(item: dict[str, Any]) -> str: n = _norm(item.get("pattern") or "") return "solid" if n in {"solid", "plain", ""} else "pattern" def extract_season(item: dict[str, Any]) -> str: n = _norm(item.get("season") or "") if "summer" in n: return "summer" if "winter" in n: return "winter" if "monsoon" in n or "rainy" in n: return "monsoon" return "all" # "All-Season" or unknown -> no restriction def extract_fabric(item: dict[str, Any]) -> str: return _norm(item.get("fabric") or "") # --------------------------------------------------------------------------- # Color scoring # --------------------------------------------------------------------------- _COMPLEMENTARY: set[frozenset] = { frozenset(["blue", "beige"]), frozenset(["blue", "khaki"]), frozenset(["black", "white"]), frozenset(["navy", "khaki"]), frozenset(["navy", "beige"]), frozenset(["navy", "white"]), frozenset(["green", "brown"]), frozenset(["olive", "tan"]), frozenset(["olive", "cream"]), frozenset(["burgundy", "grey"]), frozenset(["maroon", "white"]), frozenset(["grey", "navy"]), frozenset(["teal", "white"]), frozenset(["coral", "navy"]), frozenset(["black", "beige"]), frozenset(["black", "khaki"]), frozenset(["white", "navy"]), frozenset(["brown", "cream"]), frozenset(["mustard", "navy"]), frozenset(["mustard", "black"]), } _NEUTRALS: set[str] = { "black", "white", "grey", "gray", "beige", "cream", "tan", "navy", "khaki", } _ANALOGOUS: set[frozenset] = { frozenset(["blue", "green"]), frozenset(["blue", "teal"]), frozenset(["red", "orange"]), frozenset(["yellow", "orange"]), frozenset(["red", "maroon"]), frozenset(["purple", "pink"]), frozenset(["green", "teal"]), frozenset(["orange", "coral"]), } def _color_score(top: dict[str, Any], bottom: dict[str, Any]) -> int: c1 = extract_base_color(top.get("color") or "") c2 = extract_base_color(bottom.get("color") or "") if not c1 or not c2: return 60 pair = frozenset([c1, c2]) if pair in _COMPLEMENTARY: return 90 if c1 in _NEUTRALS and c2 in _NEUTRALS: return 50 if c1 == c2 else 82 if c1 in _NEUTRALS or c2 in _NEUTRALS: return 80 if pair in _ANALOGOUS: return 60 if c1 == c2: return 45 return 60 # --------------------------------------------------------------------------- # Style scoring # --------------------------------------------------------------------------- _STYLE_MATRIX: dict[tuple[str, str], int] = { ("casual", "casual"): 85, ("formal", "formal"): 90, ("streetwear", "streetwear"): 88, ("party", "party"): 85, ("sports", "sports"): 88, ("casual", "streetwear"): 80, ("streetwear", "casual"): 80, ("casual", "party"): 72, ("party", "casual"): 72, ("casual", "formal"): 62, ("formal", "casual"): 62, ("formal", "party"): 70, ("party", "formal"): 70, ("formal", "streetwear"): 48, ("streetwear", "formal"): 48, ("sports", "casual"): 72, ("casual", "sports"): 72, ("sports", "formal"): 28, ("formal", "sports"): 28, ("sports", "party"): 40, ("party", "sports"): 40, } def _style_score(top: dict[str, Any], bottom: dict[str, Any]) -> int: s1 = extract_style(top) s2 = extract_style(bottom) return _STYLE_MATRIX.get((s1, s2), 68) # --------------------------------------------------------------------------- # Occasion scoring # --------------------------------------------------------------------------- _STYLE_TO_OCCASIONS: dict[str, set[str]] = { "casual": {"casual", "everyday", "weekend", "college", "brunch"}, "formal": {"formal", "work", "interview", "business", "office", "wedding", "meeting"}, "party": {"party", "festive", "ethnic", "diwali", "celebration", "date"}, "sports": {"sports", "gym", "active", "outdoor", "trekking"}, "streetwear": {"casual", "streetwear", "everyday", "college"}, } def _occasion_score(occasion: str, top: dict[str, Any], bottom: dict[str, Any]) -> int: occ = _norm(occasion) if not occ: return 70 t_occ = _STYLE_TO_OCCASIONS.get(extract_style(top), set()) b_occ = _STYLE_TO_OCCASIONS.get(extract_style(bottom), set()) top_fits = occ in t_occ bottom_fits = occ in b_occ # Formal occasions have stricter requirements is_formal = occ in {"formal", "work", "interview", "business", "office", "wedding", "meeting"} if top_fits and bottom_fits: return 90 if top_fits or bottom_fits: # Partial match: lower score for formal occasions return 60 if is_formal else 70 return 25 if is_formal else 35 # --------------------------------------------------------------------------- # Fit scoring # --------------------------------------------------------------------------- _FIT_MATRIX: dict[tuple[str, str], int] = { ("slim", "slim"): 82, ("oversized", "slim"): 92, ("slim", "oversized"): 78, ("oversized", "oversized"): 55, ("regular", "regular"): 80, ("slim", "regular"): 82, ("regular", "slim"): 82, ("oversized", "regular"): 85, ("regular", "oversized"): 75, } def _fit_score(top: dict[str, Any], bottom: dict[str, Any]) -> int: f1 = extract_fit(top) f2 = extract_fit(bottom) return _FIT_MATRIX.get((f1, f2), 70) # --------------------------------------------------------------------------- # Pattern scoring # --------------------------------------------------------------------------- def _pattern_score(top: dict[str, Any], bottom: dict[str, Any]) -> int: p1 = extract_pattern(top) p2 = extract_pattern(bottom) if p1 == "pattern" and p2 == "pattern": return 55 if p1 == "pattern" or p2 == "pattern": return 88 return 75 # --------------------------------------------------------------------------- # Season / fabric penalty # --------------------------------------------------------------------------- _HEAVY_FABRICS = {"wool", "leather", "velvet", "tweed", "corduroy", "fleece"} _LIGHT_FABRICS = {"linen", "cotton", "silk", "chiffon", "georgette"} _SUMMER_PENALTY = 18 # heavy fabric in summer _WINTER_PENALTY = 12 # very light fabric in winter def _season_penalty(top: dict[str, Any], bottom: dict[str, Any]) -> int: """Returns a positive integer to subtract from the final score.""" penalty = 0 for item in (top, bottom): season = extract_season(item) fabric = extract_fabric(item) if season == "summer" and any(f in fabric for f in _HEAVY_FABRICS): penalty += _SUMMER_PENALTY if season == "winter" and any(f in fabric for f in _LIGHT_FABRICS): penalty += _WINTER_PENALTY return penalty def _blend_breakdowns(primary: dict[str, int], extras: list[dict[str, int]]) -> dict[str, int]: if not extras: return dict(primary) blended: dict[str, int] = {} for key, value in primary.items(): extra_avg = sum(extra.get(key, value) for extra in extras) / len(extras) blended[key] = round((value * 0.65) + (extra_avg * 0.35)) return blended def _other_item_label(other: dict[str, Any] | None) -> str: if not other: return "other item" color = extract_base_color(other.get("color") or "") or _norm(other.get("color") or "") or "neutral" category = str(other.get("category") or other.get("type") or "other item").strip() or "other item" return f"{color} {category}".strip() # --------------------------------------------------------------------------- # Human-readable explanation # --------------------------------------------------------------------------- def build_reason( breakdown: dict[str, int], top: dict[str, Any], bottom: dict[str, Any], occasion: str, season_pen: int, other: dict[str, Any] | None = None, ) -> str: lines: list[str] = [] c = breakdown["color"] c1 = extract_base_color(top.get("color") or "") c2 = extract_base_color(bottom.get("color") or "") if c >= 88: lines.append(f"Great color contrast — {c1} and {c2} complement each other well.") elif c >= 78: lines.append(f"Clean color pairing — one neutral ({c1 if c1 in _NEUTRALS else c2}) anchors the look.") elif c <= 60: lines.append(f"Weak color pairing — {c1} and {c2} lack contrast or clash.") s = breakdown["style"] s1, s2 = extract_style(top), extract_style(bottom) if s >= 85: lines.append(f"Consistent style ({s1}).") elif s <= 55: lines.append(f"Style mismatch: {s1} top with {s2} bottom doesn't work for most occasions.") o = breakdown["occasion"] if occasion: occ_lower = occasion.lower() is_formal = occ_lower in {"formal", "work", "interview", "business", "office", "wedding", "meeting"} if o >= 88: lines.append(f"Both pieces suit {occasion}.") elif o >= 68: lines.append(f"One piece suits {occasion}, the other is borderline.") elif o >= 50: if is_formal: lines.append(f"Pieces are casual — not ideal for formal {occasion}.") else: lines.append(f"Neither piece is well-suited to {occasion}.") else: lines.append(f"Pieces are incompatible with {occasion} dress code.") f = breakdown["fit"] f1, f2 = extract_fit(top), extract_fit(bottom) if f >= 90: lines.append(f"Excellent fit contrast — {f1} top with {f2} bottom is a strong silhouette.") elif f <= 58: lines.append(f"Both pieces are {f1} — too much volume in one direction.") if season_pen > 0: lines.append(f"Season/fabric mismatch reduced the score by {season_pen} pts.") if other: other_label = _other_item_label(other) if breakdown["style"] >= 72 and breakdown["color"] >= 72: lines.append(f"The {other_label} strengthens the finishing-layer/accessory coordination.") else: lines.append(f"The {other_label} was included in scoring, but it is not the strongest finishing piece here.") return " ".join(lines) if lines else "Decent pairing overall." def build_tip( score: int, top: dict[str, Any], bottom: dict[str, Any], other: dict[str, Any] | None = None, ) -> str: if score >= 85: if other: return f"Strong outfit. Keep the {_other_item_label(other)} as the main finishing accent." return "Solid outfit. Add a belt or watch to sharpen the look." if score >= 70: s1, s2 = extract_style(top), extract_style(bottom) if s1 != s2: return f"Swap the {s2} bottom for something more {s1} to improve cohesion." c1 = extract_base_color(top.get("color") or "") if c1 not in _NEUTRALS: return "Add a neutral layer (jacket or shoes) to tie the colours together." if other: return f"If possible, swap the {_other_item_label(other)} for a cleaner neutral accent." return "Try a different bottom colour for more visual interest." return "This combination needs work — consider changing at least one piece." # --------------------------------------------------------------------------- # Main scoring entry point # --------------------------------------------------------------------------- def compute_score( top: dict[str, Any], bottom: dict[str, Any], occasion: str = "casual", other: dict[str, Any] | None = None, ) -> tuple[int, dict[str, int]]: """ Returns (final_score, breakdown_dict). breakdown keys: color, style, occasion, fit, pattern Veto caps: - color <= 50 → final capped at 68 (monochrome / clash) - style <= 48 → final capped at 58 (hard style mismatch) - pattern == 55 (both patterned) AND color <= 80 → cap at 72 """ raw_scores: dict[str, int] = { "color": _color_score(top, bottom), "style": _style_score(top, bottom), "occasion": _occasion_score(occasion, top, bottom), "fit": _fit_score(top, bottom), "pattern": _pattern_score(top, bottom), } extra_penalty = 0 if other: raw_scores = _blend_breakdowns( raw_scores, [ { "color": _color_score(top, other), "style": _style_score(top, other), "occasion": _occasion_score(occasion, top, other), "fit": _fit_score(top, other), "pattern": _pattern_score(top, other), }, { "color": _color_score(bottom, other), "style": _style_score(bottom, other), "occasion": _occasion_score(occasion, bottom, other), "fit": _fit_score(bottom, other), "pattern": _pattern_score(bottom, other), }, ], ) extra_penalty = round( (_season_penalty(top, other) + _season_penalty(bottom, other)) / 2 ) weighted = sum(raw_scores[k] * WEIGHTS[k] for k in WEIGHTS) penalty = ( round((_season_penalty(top, bottom) * 0.65) + (extra_penalty * 0.35)) if other else _season_penalty(top, bottom) ) final = max(0, min(100, round(weighted - penalty))) # Veto caps — a fatal flaw in one dimension overrides a good weighted average if raw_scores["color"] <= 50: final = min(final, 68) if raw_scores["style"] <= 48: final = min(final, 58) if raw_scores["occasion"] <= 40: # Neither piece suited to the occasion — cap final score final = min(final, 52) if raw_scores["pattern"] == 55 and raw_scores["color"] <= 80: final = min(final, 72) return final, raw_scores def score_pair_full( top: dict[str, Any], bottom: dict[str, Any], occasion: str = "casual", other: dict[str, Any] | None = None, ) -> dict[str, Any]: """ Returns the full scoring dict that all endpoints expect: score, breakdown, reason, tip, engine_version """ score, breakdown = compute_score(top, bottom, occasion, other=other) penalty = _season_penalty(top, bottom) if other: other_penalty = round( (_season_penalty(top, other) + _season_penalty(bottom, other)) / 2 ) penalty = round((penalty * 0.65) + (other_penalty * 0.35)) return { "score": score, "breakdown": breakdown, "reason": build_reason(breakdown, top, bottom, occasion, penalty, other=other), "tip": build_tip(score, top, bottom, other=other), "engine_version": "scoring-v2", } # --------------------------------------------------------------------------- # Diversity penalty (non-mutating) # --------------------------------------------------------------------------- def _is_similar(a: dict[str, Any], b: dict[str, Any]) -> bool: return ( extract_base_color(a["top"].get("color") or "") == extract_base_color(b["top"].get("color") or "") and extract_base_color(a["bottom"].get("color") or "") == extract_base_color(b["bottom"].get("color") or "") and extract_base_color((a.get("other") or {}).get("color") or "") == extract_base_color((b.get("other") or {}).get("color") or "") ) def _apply_diversity_penalty(pairs: list[dict[str, Any]]) -> list[dict[str, Any]]: result: list[dict[str, Any]] = [] for pair in pairs: penalty = sum(10 for sel in result if _is_similar(pair, sel)) adjusted = copy.copy(pair) adjusted["score"] = max(0, pair["score"] - penalty) result.append(adjusted) return result # --------------------------------------------------------------------------- # Recommender # --------------------------------------------------------------------------- def recommend_outfits( tops: list[dict[str, Any]], bottoms: list[dict[str, Any]], occasion: str = "casual", others: list[dict[str, Any]] | None = None, locked_top: dict[str, Any] | None = None, locked_bottom: dict[str, Any] | None = None, locked_other: dict[str, Any] | None = None, ) -> list[dict[str, Any]]: """ Returns up to TOP_K scored pairs, sorted best-first. Each entry: {top, bottom, score, breakdown, reason, tip} """ other_options = [locked_other] if locked_other else ([None] + list(others or [])) if locked_top and locked_bottom: candidates = [(locked_top, locked_bottom, other) for other in other_options] elif locked_top: candidates = [(locked_top, b, other) for b in bottoms for other in other_options] elif locked_bottom: candidates = [(t, locked_bottom, other) for t in tops for other in other_options] else: candidates = [(t, b, other) for t in tops for b in bottoms for other in other_options] scored: list[dict[str, Any]] = [] for top, bottom, other in candidates: result = score_pair_full(top, bottom, occasion, other=other) scored.append({ "top": top, "bottom": bottom, "other": other, "score": result["score"], "breakdown": result["breakdown"], "reason": result["reason"], "tip": result["tip"], }) scored.sort(key=lambda x: x["score"], reverse=True) scored = _apply_diversity_penalty(scored) scored.sort(key=lambda x: x["score"], reverse=True) return scored[:TOP_K]