Spaces:
Running
Running
| """ | |
| 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] | |