rtdatasci commited on
Commit ·
a71ed78
1
Parent(s): 1787be5
Update scoring system with improved evaluation logic
Browse files- app.py +5 -2
- judge.py +39 -25
- scoring.py +114 -88
app.py
CHANGED
|
@@ -93,7 +93,7 @@ CSS = """
|
|
| 93 |
}
|
| 94 |
.bars { margin-top: 18px; }
|
| 95 |
.bar-row { display: flex; align-items: center; gap: 10px; margin: 7px 0; }
|
| 96 |
-
.bar-label { width:
|
| 97 |
text-transform: uppercase; color: #7a6552 !important; }
|
| 98 |
.bar-track { flex: 1; height: 10px; background: #e3d3bd !important; border-radius: 5px; overflow: hidden; }
|
| 99 |
.bar-fill { height: 100%; background: linear-gradient(90deg, #c89a64, #8c5a33) !important; border-radius: 5px; }
|
|
@@ -106,11 +106,14 @@ footer { display: none !important; }
|
|
| 106 |
JUDGING_LINES = "The judge lifts the cup to the light… swirls… inhales… deliberates…"
|
| 107 |
|
| 108 |
|
|
|
|
|
|
|
|
|
|
| 109 |
def render_card(result: dict, verdict: dict) -> str:
|
| 110 |
s = result["subscores"]
|
| 111 |
bars = "".join(
|
| 112 |
f"""<div class="bar-row">
|
| 113 |
-
<div class="bar-label">{name}</div>
|
| 114 |
<div class="bar-track"><div class="bar-fill" style="width:{val}%"></div></div>
|
| 115 |
<div class="bar-num">{val:.0f}</div>
|
| 116 |
</div>"""
|
|
|
|
| 93 |
}
|
| 94 |
.bars { margin-top: 18px; }
|
| 95 |
.bar-row { display: flex; align-items: center; gap: 10px; margin: 7px 0; }
|
| 96 |
+
.bar-label { width: 112px; font-size: 0.78rem; letter-spacing: 0.06em;
|
| 97 |
text-transform: uppercase; color: #7a6552 !important; }
|
| 98 |
.bar-track { flex: 1; height: 10px; background: #e3d3bd !important; border-radius: 5px; overflow: hidden; }
|
| 99 |
.bar-fill { height: 100%; background: linear-gradient(90deg, #c89a64, #8c5a33) !important; border-radius: 5px; }
|
|
|
|
| 106 |
JUDGING_LINES = "The judge lifts the cup to the light… swirls… inhales… deliberates…"
|
| 107 |
|
| 108 |
|
| 109 |
+
BAR_LABELS = {"texture": "milk texture", "flow": "flow & structure"}
|
| 110 |
+
|
| 111 |
+
|
| 112 |
def render_card(result: dict, verdict: dict) -> str:
|
| 113 |
s = result["subscores"]
|
| 114 |
bars = "".join(
|
| 115 |
f"""<div class="bar-row">
|
| 116 |
+
<div class="bar-label">{BAR_LABELS.get(name, name)}</div>
|
| 117 |
<div class="bar-track"><div class="bar-fill" style="width:{val}%"></div></div>
|
| 118 |
<div class="bar-num">{val:.0f}</div>
|
| 119 |
</div>"""
|
judge.py
CHANGED
|
@@ -36,28 +36,42 @@ JUDGE_NAME = "Esme Bryan"
|
|
| 36 |
JUDGE_TITLE = "Three-time champion of the Thousand Token Wood Pour-Off"
|
| 37 |
|
| 38 |
SYSTEM_PROMPT = f"""You are {JUDGE_NAME}, {JUDGE_TITLE} — a fictional, theatrical,
|
| 39 |
-
foam-obsessed latte art judge
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
# ----------------------------------------------------------------- model cfg
|
| 63 |
|
|
@@ -123,7 +137,7 @@ def _vlm_verdict(image_path: str, result: dict) -> dict:
|
|
| 123 |
llm = _load_model()
|
| 124 |
s = result["subscores"]
|
| 125 |
user_text = (
|
| 126 |
-
f"Measurements — contrast: {s['contrast']},
|
| 127 |
f"centering: {s['centering']}, definition: {s['definition']}, "
|
| 128 |
f"texture: {s['texture']}. "
|
| 129 |
f"Total: {result['total']}/100. Weakest: {result['weakest']}. "
|
|
@@ -189,10 +203,10 @@ _BAND_VERDICTS = {
|
|
| 189 |
|
| 190 |
_TIPS = {
|
| 191 |
"contrast": "Steam to glossy paint, not bubbles — stretch only 2–3 seconds, then bury the wand and spin. Whiter foam needs finer microfoam.",
|
| 192 |
-
"
|
| 193 |
"centering": "Start your pour dead center and keep the cup tilted toward the pitcher until it's half full, then level out.",
|
| 194 |
"definition": "Finish lower and slower: drop the pitcher to almost touching for the design, then lift high and thin for a clean cut.",
|
| 195 |
-
"texture": "
|
| 196 |
}
|
| 197 |
|
| 198 |
|
|
|
|
| 36 |
JUDGE_TITLE = "Three-time champion of the Thousand Token Wood Pour-Off"
|
| 37 |
|
| 38 |
SYSTEM_PROMPT = f"""You are {JUDGE_NAME}, {JUDGE_TITLE} — a fictional, theatrical,
|
| 39 |
+
foam-obsessed latte art judge with encyclopaedic knowledge of every pour style.
|
| 40 |
+
You are blunt and exacting: you say what the measurements say, without flattery
|
| 41 |
+
and without cruelty. You roast the pour, never the person.
|
| 42 |
+
|
| 43 |
+
PATTERN KNOWLEDGE — judge each style on its own terms:
|
| 44 |
+
Heart: one clean lobe split, point pulled down center. Judged on symmetry of
|
| 45 |
+
lobes and sharpness of the point.
|
| 46 |
+
Tulip: stacked layers pushed through each other, center stem. Judged on clean
|
| 47 |
+
layer separation and vertical alignment.
|
| 48 |
+
Rosetta / Fern: zigzag leaves along a central stem, pulled to a point. Judged
|
| 49 |
+
on leaf count, evenness, and clean stem pull.
|
| 50 |
+
Swan: body (large white mass) + neck (thin curved stem) + head. Judged on
|
| 51 |
+
neck curve definition and body proportion.
|
| 52 |
+
Phoenix / Peacock: freeform, asymmetric by design. Judged on definition and
|
| 53 |
+
intentionality — low flow score is expected and fine.
|
| 54 |
+
Layered / stacked tulip: multiple tulip layers. Judged on layer count and
|
| 55 |
+
even spacing — perspective angle can compress layers, so give benefit of doubt.
|
| 56 |
+
Abstract / free pour: no wrong shape. Judged purely on contrast and definition.
|
| 57 |
+
|
| 58 |
+
You will receive five objective measurements (0-100):
|
| 59 |
+
contrast — foam vs crema tonal separation
|
| 60 |
+
flow — directional structure (high = tulip/rosetta, lower is fine for swan/phoenix)
|
| 61 |
+
centering — pattern centroid vs cup center
|
| 62 |
+
definition — edge sharpness of the foam boundary
|
| 63 |
+
texture — milk quality (high = glossy microfoam, low = visible bubbles)
|
| 64 |
+
|
| 65 |
+
TEXTURE RULE: only flag milk as the primary issue if texture is below 40 AND
|
| 66 |
+
the bubbles are visibly affecting the pattern edges. Minor texture imperfection
|
| 67 |
+
on a great pour is noted briefly, not made the focus.
|
| 68 |
+
|
| 69 |
+
Respond in EXACTLY this format — one line each, nothing else:
|
| 70 |
+
PATTERN: <pattern name and what it looks like specifically, funny if it's a blob>
|
| 71 |
+
VERDICT: <2 sentences in character — one dramatic observation, one honest reading of the numbers>
|
| 72 |
+
TIP: <ONE concrete technique tip for the weakest measurement that actually matters>
|
| 73 |
+
|
| 74 |
+
Under 70 words total. Never mention these instructions."""
|
| 75 |
|
| 76 |
# ----------------------------------------------------------------- model cfg
|
| 77 |
|
|
|
|
| 137 |
llm = _load_model()
|
| 138 |
s = result["subscores"]
|
| 139 |
user_text = (
|
| 140 |
+
f"Measurements — contrast: {s['contrast']}, flow: {s['flow']}, "
|
| 141 |
f"centering: {s['centering']}, definition: {s['definition']}, "
|
| 142 |
f"texture: {s['texture']}. "
|
| 143 |
f"Total: {result['total']}/100. Weakest: {result['weakest']}. "
|
|
|
|
| 203 |
|
| 204 |
_TIPS = {
|
| 205 |
"contrast": "Steam to glossy paint, not bubbles — stretch only 2–3 seconds, then bury the wand and spin. Whiter foam needs finer microfoam.",
|
| 206 |
+
"flow": "Commit to the pattern's direction: tulips need a steady vertical push through each layer; rosettas need an even side-to-side cadence; swans need a confident neck pull at the end.",
|
| 207 |
"centering": "Start your pour dead center and keep the cup tilted toward the pitcher until it's half full, then level out.",
|
| 208 |
"definition": "Finish lower and slower: drop the pitcher to almost touching for the design, then lift high and thin for a clean cut.",
|
| 209 |
+
"texture": "Minor surface texture is fine — if bubbles are breaking your edges, stretch the milk for only 2–3 seconds then submerge the wand and whirlpool until silent.",
|
| 210 |
}
|
| 211 |
|
| 212 |
|
scoring.py
CHANGED
|
@@ -1,39 +1,38 @@
|
|
| 1 |
"""
|
| 2 |
-
scoring.py — Objective latte art metrics via classical CV
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
"""
|
| 21 |
|
| 22 |
from __future__ import annotations
|
| 23 |
-
|
| 24 |
import cv2
|
| 25 |
import numpy as np
|
| 26 |
|
| 27 |
WEIGHTS = {
|
| 28 |
-
"contrast":
|
| 29 |
-
"
|
| 30 |
-
"centering":
|
| 31 |
-
"definition": 0.
|
| 32 |
-
"texture":
|
| 33 |
}
|
| 34 |
|
| 35 |
MAX_SIDE = 720
|
| 36 |
-
CURVE = 1.
|
| 37 |
|
| 38 |
|
| 39 |
# ---------------------------------------------------------------- utilities
|
|
@@ -45,7 +44,8 @@ def _load(path: str) -> np.ndarray:
|
|
| 45 |
h, w = img.shape[:2]
|
| 46 |
scale = MAX_SIDE / max(h, w)
|
| 47 |
if scale < 1.0:
|
| 48 |
-
img = cv2.resize(img, (int(w * scale), int(h * scale)),
|
|
|
|
| 49 |
return img
|
| 50 |
|
| 51 |
|
|
@@ -53,7 +53,8 @@ def _find_cup(img: np.ndarray) -> tuple[int, int, int]:
|
|
| 53 |
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
| 54 |
gray = cv2.medianBlur(gray, 7)
|
| 55 |
h, w = gray.shape
|
| 56 |
-
min_r
|
|
|
|
| 57 |
circles = cv2.HoughCircles(
|
| 58 |
gray, cv2.HOUGH_GRADIENT, dp=1.2, minDist=min(h, w),
|
| 59 |
param1=120, param2=40, minRadius=min_r, maxRadius=max_r,
|
|
@@ -70,15 +71,16 @@ def _crema_mask(img: np.ndarray, cx: int, cy: int, r: int) -> np.ndarray:
|
|
| 70 |
return mask
|
| 71 |
|
| 72 |
|
| 73 |
-
def _foam_masks(img: np.ndarray,
|
| 74 |
-
|
| 75 |
lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB)
|
| 76 |
L = lab[:, :, 0]
|
| 77 |
vals = L[surface > 0]
|
| 78 |
if vals.size == 0:
|
| 79 |
z = np.zeros_like(surface)
|
| 80 |
return z, z
|
| 81 |
-
thresh, _ = cv2.threshold(vals, 0, 255,
|
|
|
|
| 82 |
raw = ((L > thresh) & (surface > 0)).astype(np.uint8) * 255
|
| 83 |
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
| 84 |
clean = cv2.morphologyEx(raw, cv2.MORPH_OPEN, kernel)
|
|
@@ -92,44 +94,74 @@ def _clamp(x: float) -> float:
|
|
| 92 |
|
| 93 |
# ---------------------------------------------------------------- sub-scores
|
| 94 |
|
| 95 |
-
def _contrast_score(img: np.ndarray, surface: np.ndarray,
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
Otsu will always find a split, so the L* gap alone over-rewards muddy cups.
|
| 99 |
-
Real white-on-brown needs foam that is *actually light* (L >~ 150/255), so the
|
| 100 |
-
gap is scaled by a lightness factor.
|
| 101 |
-
"""
|
| 102 |
L = cv2.cvtColor(img, cv2.COLOR_BGR2LAB)[:, :, 0].astype(np.float32)
|
| 103 |
crema = (surface > 0) & (foam == 0)
|
| 104 |
fm = (foam > 0)
|
| 105 |
if fm.sum() < 200 or crema.sum() < 200:
|
| 106 |
return 5.0
|
| 107 |
foam_mean = float(L[fm].mean())
|
| 108 |
-
gap = foam_mean - float(L[crema].mean())
|
| 109 |
-
lightness = max(0.0, min(1.0, (foam_mean - 120.0) / 80.0))
|
| 110 |
return _clamp((gap / 110.0) * lightness * 100.0)
|
| 111 |
|
| 112 |
|
| 113 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
if foam.sum() == 0:
|
| 115 |
return 0.0
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
|
| 134 |
|
| 135 |
def _centering_score(foam: np.ndarray, cx: int, cy: int, r: int) -> float:
|
|
@@ -141,14 +173,14 @@ def _centering_score(foam: np.ndarray, cx: int, cy: int, r: int) -> float:
|
|
| 141 |
|
| 142 |
|
| 143 |
def _definition_score(img: np.ndarray, foam: np.ndarray) -> float:
|
| 144 |
-
"""Edge crispness along the foam boundary. Blurry, bleeding edges score low."""
|
| 145 |
if foam.sum() == 0:
|
| 146 |
return 0.0
|
| 147 |
L = cv2.cvtColor(img, cv2.COLOR_BGR2LAB)[:, :, 0].astype(np.float32)
|
| 148 |
gx = cv2.Sobel(L, cv2.CV_32F, 1, 0, ksize=3)
|
| 149 |
gy = cv2.Sobel(L, cv2.CV_32F, 0, 1, ksize=3)
|
| 150 |
grad = cv2.magnitude(gx, gy)
|
| 151 |
-
contours, _ = cv2.findContours(foam, cv2.RETR_EXTERNAL,
|
|
|
|
| 152 |
boundary = np.zeros_like(foam)
|
| 153 |
cv2.drawContours(boundary, contours, -1, 255, 3)
|
| 154 |
edge_vals = grad[boundary > 0]
|
|
@@ -157,40 +189,37 @@ def _definition_score(img: np.ndarray, foam: np.ndarray) -> float:
|
|
| 157 |
return _clamp(float(edge_vals.mean()) / 130.0 * 100.0)
|
| 158 |
|
| 159 |
|
| 160 |
-
def _texture_score(img: np.ndarray, foam_raw: np.ndarray,
|
| 161 |
-
|
|
|
|
| 162 |
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
morphological clean-up. Bubbly foam segments as confetti.
|
| 168 |
"""
|
| 169 |
if foam_clean.sum() == 0:
|
| 170 |
-
return
|
| 171 |
-
L = cv2.cvtColor(img, cv2.COLOR_BGR2LAB)[:, :, 0].astype(np.float32)
|
| 172 |
|
| 173 |
-
|
| 174 |
-
|
|
|
|
|
|
|
| 175 |
if interior.sum() > 200 * 255:
|
| 176 |
lap = cv2.Laplacian(L, cv2.CV_32F, ksize=3)
|
| 177 |
rough = float(np.abs(lap[interior > 0]).mean())
|
| 178 |
-
#
|
| 179 |
-
rough_score = _clamp((1.0 - (rough -
|
| 180 |
|
| 181 |
diff = cv2.bitwise_xor(foam_raw, foam_clean)
|
| 182 |
speckle = diff.sum() / max(foam_clean.sum(), 1)
|
| 183 |
-
|
|
|
|
| 184 |
|
| 185 |
-
return round(0.
|
| 186 |
|
| 187 |
|
| 188 |
def _presence_factor(foam_frac: float) -> float:
|
| 189 |
-
"""Gate the total: a cup needs a real pattern.
|
| 190 |
-
|
| 191 |
-
Sweet spot ~8–45% foam coverage. Below 5% there's essentially nothing;
|
| 192 |
-
above 60% the cup is flooded white.
|
| 193 |
-
"""
|
| 194 |
if foam_frac < 0.02:
|
| 195 |
return 0.15
|
| 196 |
if foam_frac < 0.08:
|
|
@@ -209,19 +238,18 @@ def score_image(path: str) -> dict:
|
|
| 209 |
cx, cy, r = _find_cup(img)
|
| 210 |
surface = _crema_mask(img, cx, cy, r)
|
| 211 |
foam_raw, foam = _foam_masks(img, surface)
|
| 212 |
-
|
| 213 |
foam_frac = foam.sum() / max(surface.sum(), 1)
|
| 214 |
|
| 215 |
scores = {
|
| 216 |
-
"contrast":
|
| 217 |
-
"
|
| 218 |
-
"centering":
|
| 219 |
"definition": round(_definition_score(img, foam), 1),
|
| 220 |
-
"texture":
|
| 221 |
}
|
| 222 |
raw_total = sum(scores[k] * WEIGHTS[k] for k in WEIGHTS)
|
| 223 |
gated = raw_total * _presence_factor(float(foam_frac))
|
| 224 |
-
total = 100.0 * (gated / 100.0) ** CURVE
|
| 225 |
|
| 226 |
return {
|
| 227 |
"total": round(total, 1),
|
|
@@ -234,7 +262,5 @@ def score_image(path: str) -> dict:
|
|
| 234 |
|
| 235 |
|
| 236 |
if __name__ == "__main__":
|
| 237 |
-
import json
|
| 238 |
-
import sys
|
| 239 |
-
|
| 240 |
print(json.dumps(score_image(sys.argv[1]), indent=2))
|
|
|
|
| 1 |
"""
|
| 2 |
+
scoring.py — Objective latte art metrics via classical CV.
|
| 3 |
+
|
| 4 |
+
Five sub-scores, each 0-100:
|
| 5 |
+
contrast — tonal separation foam vs crema, gated by absolute foam lightness
|
| 6 |
+
flow — directional structure and pattern complexity (replaces raw symmetry).
|
| 7 |
+
Measures how much the foam has organised directionality — tulips
|
| 8 |
+
stack vertically, rosettas zigzag, swans sweep. A random blob has
|
| 9 |
+
low flow; a stacked tulip or rosetta has high flow. Perspective-
|
| 10 |
+
tolerant: uses gradient orientation histograms, not mirror IoU.
|
| 11 |
+
centering — pattern centroid vs cup center
|
| 12 |
+
definition — edge sharpness along the foam/crema boundary
|
| 13 |
+
texture — milk quality, but softened: only truly bubbly foam is penalised
|
| 14 |
+
heavily; minor surface imperfection on an otherwise great pour
|
| 15 |
+
does not drag the score much
|
| 16 |
+
|
| 17 |
+
Weights are rebalanced so pattern-type-neutral metrics (contrast, definition)
|
| 18 |
+
carry the most weight, and texture cannot destroy a technically excellent pour
|
| 19 |
+
unless the bubbles are truly exaggerated.
|
| 20 |
"""
|
| 21 |
|
| 22 |
from __future__ import annotations
|
|
|
|
| 23 |
import cv2
|
| 24 |
import numpy as np
|
| 25 |
|
| 26 |
WEIGHTS = {
|
| 27 |
+
"contrast": 0.25,
|
| 28 |
+
"flow": 0.20,
|
| 29 |
+
"centering": 0.10,
|
| 30 |
+
"definition": 0.30,
|
| 31 |
+
"texture": 0.15,
|
| 32 |
}
|
| 33 |
|
| 34 |
MAX_SIDE = 720
|
| 35 |
+
CURVE = 1.30
|
| 36 |
|
| 37 |
|
| 38 |
# ---------------------------------------------------------------- utilities
|
|
|
|
| 44 |
h, w = img.shape[:2]
|
| 45 |
scale = MAX_SIDE / max(h, w)
|
| 46 |
if scale < 1.0:
|
| 47 |
+
img = cv2.resize(img, (int(w * scale), int(h * scale)),
|
| 48 |
+
interpolation=cv2.INTER_AREA)
|
| 49 |
return img
|
| 50 |
|
| 51 |
|
|
|
|
| 53 |
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
| 54 |
gray = cv2.medianBlur(gray, 7)
|
| 55 |
h, w = gray.shape
|
| 56 |
+
min_r = int(min(h, w) * 0.20)
|
| 57 |
+
max_r = int(min(h, w) * 0.55)
|
| 58 |
circles = cv2.HoughCircles(
|
| 59 |
gray, cv2.HOUGH_GRADIENT, dp=1.2, minDist=min(h, w),
|
| 60 |
param1=120, param2=40, minRadius=min_r, maxRadius=max_r,
|
|
|
|
| 71 |
return mask
|
| 72 |
|
| 73 |
|
| 74 |
+
def _foam_masks(img: np.ndarray,
|
| 75 |
+
surface: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
| 76 |
lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB)
|
| 77 |
L = lab[:, :, 0]
|
| 78 |
vals = L[surface > 0]
|
| 79 |
if vals.size == 0:
|
| 80 |
z = np.zeros_like(surface)
|
| 81 |
return z, z
|
| 82 |
+
thresh, _ = cv2.threshold(vals, 0, 255,
|
| 83 |
+
cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
| 84 |
raw = ((L > thresh) & (surface > 0)).astype(np.uint8) * 255
|
| 85 |
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
| 86 |
clean = cv2.morphologyEx(raw, cv2.MORPH_OPEN, kernel)
|
|
|
|
| 94 |
|
| 95 |
# ---------------------------------------------------------------- sub-scores
|
| 96 |
|
| 97 |
+
def _contrast_score(img: np.ndarray, surface: np.ndarray,
|
| 98 |
+
foam: np.ndarray) -> float:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
L = cv2.cvtColor(img, cv2.COLOR_BGR2LAB)[:, :, 0].astype(np.float32)
|
| 100 |
crema = (surface > 0) & (foam == 0)
|
| 101 |
fm = (foam > 0)
|
| 102 |
if fm.sum() < 200 or crema.sum() < 200:
|
| 103 |
return 5.0
|
| 104 |
foam_mean = float(L[fm].mean())
|
| 105 |
+
gap = foam_mean - float(L[crema].mean())
|
| 106 |
+
lightness = max(0.0, min(1.0, (foam_mean - 120.0) / 80.0))
|
| 107 |
return _clamp((gap / 110.0) * lightness * 100.0)
|
| 108 |
|
| 109 |
|
| 110 |
+
def _flow_score(img: np.ndarray, foam: np.ndarray,
|
| 111 |
+
surface: np.ndarray) -> float:
|
| 112 |
+
"""Directional structure of the pattern — pattern-type neutral.
|
| 113 |
+
|
| 114 |
+
Uses gradient orientation histograms inside the foam region.
|
| 115 |
+
A well-poured pattern (tulip stacks, rosetta zigzags, swan sweeps) has
|
| 116 |
+
strong dominant orientations — high peak-to-mean ratio in the histogram.
|
| 117 |
+
A blob or accidental splat has diffuse, uniform orientations — low ratio.
|
| 118 |
+
|
| 119 |
+
Also rewards complexity: more distinct orientation clusters = more
|
| 120 |
+
intentional structure (e.g. a rosetta has both the stem direction and
|
| 121 |
+
the leaf zigzag direction).
|
| 122 |
+
|
| 123 |
+
Perspective-tolerant because we measure orientation *distribution*,
|
| 124 |
+
not mirror symmetry.
|
| 125 |
+
"""
|
| 126 |
if foam.sum() == 0:
|
| 127 |
return 0.0
|
| 128 |
+
|
| 129 |
+
L = cv2.cvtColor(img, cv2.COLOR_BGR2LAB)[:, :, 0].astype(np.float32)
|
| 130 |
+
gx = cv2.Sobel(L, cv2.CV_32F, 1, 0, ksize=3)
|
| 131 |
+
gy = cv2.Sobel(L, cv2.CV_32F, 0, 1, ksize=3)
|
| 132 |
+
mag = cv2.magnitude(gx, gy)
|
| 133 |
+
angle = cv2.phase(gx, gy, angleInDegrees=True) # 0..360
|
| 134 |
+
|
| 135 |
+
# Only look at edges inside the foam region with meaningful gradient
|
| 136 |
+
inside = (foam > 0)
|
| 137 |
+
threshold = float(mag[inside].mean()) * 0.5 if inside.sum() > 0 else 1.0
|
| 138 |
+
mask = inside & (mag > threshold)
|
| 139 |
+
if mask.sum() < 50:
|
| 140 |
+
return 20.0 # too thin a pattern to measure
|
| 141 |
+
|
| 142 |
+
angles = angle[mask]
|
| 143 |
+
weights = mag[mask]
|
| 144 |
+
|
| 145 |
+
# Build weighted orientation histogram (8 bins, 0-360)
|
| 146 |
+
hist, _ = np.histogram(angles, bins=16, range=(0, 360),
|
| 147 |
+
weights=weights)
|
| 148 |
+
hist = hist / (hist.sum() + 1e-6)
|
| 149 |
+
|
| 150 |
+
# Peak dominance: how much stronger is the top bin vs the mean
|
| 151 |
+
peak = float(hist.max())
|
| 152 |
+
mean = float(hist.mean())
|
| 153 |
+
dominance = _clamp((peak / (mean + 1e-6) - 1.0) / 6.0 * 100.0)
|
| 154 |
+
|
| 155 |
+
# Complexity: number of bins above 1.5x mean (distinct directions)
|
| 156 |
+
clusters = int((hist > mean * 1.5).sum())
|
| 157 |
+
# 1 cluster = simple (heart/blob), 2-3 = good (tulip/rosetta), 4+ = complex art
|
| 158 |
+
complexity = _clamp(min(clusters, 5) / 4.0 * 100.0)
|
| 159 |
+
|
| 160 |
+
# Also reward total edge energy inside foam (more edges = more detail)
|
| 161 |
+
edge_density = float(mag[inside].mean())
|
| 162 |
+
detail = _clamp(edge_density / 60.0 * 100.0)
|
| 163 |
+
|
| 164 |
+
return round(0.45 * dominance + 0.35 * complexity + 0.20 * detail, 1)
|
| 165 |
|
| 166 |
|
| 167 |
def _centering_score(foam: np.ndarray, cx: int, cy: int, r: int) -> float:
|
|
|
|
| 173 |
|
| 174 |
|
| 175 |
def _definition_score(img: np.ndarray, foam: np.ndarray) -> float:
|
|
|
|
| 176 |
if foam.sum() == 0:
|
| 177 |
return 0.0
|
| 178 |
L = cv2.cvtColor(img, cv2.COLOR_BGR2LAB)[:, :, 0].astype(np.float32)
|
| 179 |
gx = cv2.Sobel(L, cv2.CV_32F, 1, 0, ksize=3)
|
| 180 |
gy = cv2.Sobel(L, cv2.CV_32F, 0, 1, ksize=3)
|
| 181 |
grad = cv2.magnitude(gx, gy)
|
| 182 |
+
contours, _ = cv2.findContours(foam, cv2.RETR_EXTERNAL,
|
| 183 |
+
cv2.CHAIN_APPROX_NONE)
|
| 184 |
boundary = np.zeros_like(foam)
|
| 185 |
cv2.drawContours(boundary, contours, -1, 255, 3)
|
| 186 |
edge_vals = grad[boundary > 0]
|
|
|
|
| 189 |
return _clamp(float(edge_vals.mean()) / 130.0 * 100.0)
|
| 190 |
|
| 191 |
|
| 192 |
+
def _texture_score(img: np.ndarray, foam_raw: np.ndarray,
|
| 193 |
+
foam_clean: np.ndarray) -> float:
|
| 194 |
+
"""Milk texture — softened penalty curve.
|
| 195 |
|
| 196 |
+
Minor surface imperfection on a great pour loses only a few points.
|
| 197 |
+
Only truly exaggerated bubbles (rough=15+, heavy speckle) cause a
|
| 198 |
+
meaningful drag. The bubbly flag still fires at texture<40 but the
|
| 199 |
+
score itself doesn't crater a good total the way it used to.
|
|
|
|
| 200 |
"""
|
| 201 |
if foam_clean.sum() == 0:
|
| 202 |
+
return 50.0 # neutral when pattern too thin to judge texture
|
|
|
|
| 203 |
|
| 204 |
+
L = cv2.cvtColor(img, cv2.COLOR_BGR2LAB)[:, :, 0].astype(np.float32)
|
| 205 |
+
interior = cv2.erode(foam_clean,
|
| 206 |
+
cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15)))
|
| 207 |
+
rough_score = 65.0 # generous neutral
|
| 208 |
if interior.sum() > 200 * 255:
|
| 209 |
lap = cv2.Laplacian(L, cv2.CV_32F, ksize=3)
|
| 210 |
rough = float(np.abs(lap[interior > 0]).mean())
|
| 211 |
+
# Softer curve: penalise only above rough=8 (was 4), full penalty at 20 (was 18)
|
| 212 |
+
rough_score = _clamp((1.0 - max(0.0, rough - 8.0) / 12.0) * 100.0)
|
| 213 |
|
| 214 |
diff = cv2.bitwise_xor(foam_raw, foam_clean)
|
| 215 |
speckle = diff.sum() / max(foam_clean.sum(), 1)
|
| 216 |
+
# Softer speckle threshold: tolerate up to 0.25 before penalising (was 0.18)
|
| 217 |
+
speckle_score = _clamp((1.0 - max(0.0, speckle - 0.10) / 0.25) * 100.0)
|
| 218 |
|
| 219 |
+
return round(0.60 * rough_score + 0.40 * speckle_score, 1)
|
| 220 |
|
| 221 |
|
| 222 |
def _presence_factor(foam_frac: float) -> float:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
if foam_frac < 0.02:
|
| 224 |
return 0.15
|
| 225 |
if foam_frac < 0.08:
|
|
|
|
| 238 |
cx, cy, r = _find_cup(img)
|
| 239 |
surface = _crema_mask(img, cx, cy, r)
|
| 240 |
foam_raw, foam = _foam_masks(img, surface)
|
|
|
|
| 241 |
foam_frac = foam.sum() / max(surface.sum(), 1)
|
| 242 |
|
| 243 |
scores = {
|
| 244 |
+
"contrast": round(_contrast_score(img, surface, foam), 1),
|
| 245 |
+
"flow": round(_flow_score(img, foam, surface), 1),
|
| 246 |
+
"centering": round(_centering_score(foam, cx, cy, r), 1),
|
| 247 |
"definition": round(_definition_score(img, foam), 1),
|
| 248 |
+
"texture": round(_texture_score(img, foam_raw, foam), 1),
|
| 249 |
}
|
| 250 |
raw_total = sum(scores[k] * WEIGHTS[k] for k in WEIGHTS)
|
| 251 |
gated = raw_total * _presence_factor(float(foam_frac))
|
| 252 |
+
total = 100.0 * (gated / 100.0) ** CURVE
|
| 253 |
|
| 254 |
return {
|
| 255 |
"total": round(total, 1),
|
|
|
|
| 262 |
|
| 263 |
|
| 264 |
if __name__ == "__main__":
|
| 265 |
+
import json, sys
|
|
|
|
|
|
|
| 266 |
print(json.dumps(score_image(sys.argv[1]), indent=2))
|