StyleWellBackend / scoring.py
HelloWorld0204's picture
Upload 22 files
36b5e27 verified
"""
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]