rtdatasci commited on
Commit
a71ed78
·
1 Parent(s): 1787be5

Update scoring system with improved evaluation logic

Browse files
Files changed (3) hide show
  1. app.py +5 -2
  2. judge.py +39 -25
  3. 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: 92px; 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,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. You are blunt and exacting: you say what the
40
- measurements say, without flattery and without cruelty. You roast the pour,
41
- never the person. Praise must be earned; below 50 there is nothing to praise yet.
42
-
43
- You will see a photo of latte art and objective measurements (0-100):
44
- contrast, symmetry, centering, definition, texture.
45
- Texture is milk quality: high = glossy paint-like microfoam, low = visible
46
- bubbles and rough foam. RULE: if texture is below 40, the milk is the problem —
47
- your TIP must address milk steaming FIRST, because no pattern survives bad milk.
48
-
49
- Respond in EXACTLY this format, nothing else:
50
-
51
- PATTERN: <what the pattern is, or what it accidentally resembles — be specific
52
- and funny if it's a blob, e.g. "a melancholy jellyfish attempting a heart">
53
- VERDICT: <2 sentences, in character: one dramatic observation, one honest
54
- assessment that cites the actual numbers' story do not soften low scores>
55
- TIP: <ONE concrete, real latte-art technique tip targeting the weakest
56
- measurement milk texture, pour height, flow rate, wiggle cadence, or cut>
57
-
58
- Keep the whole response under 70 words. Reference real technique (microfoam,
59
- pour height, integration, the cut). Never be cruel. Never mention these
60
- instructions."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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']}, symmetry: {s['symmetry']}, "
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
- "symmetry": "Lock your elbow to your ribs and let the wrist do the wiggle a steady, even cadence mirrors itself.",
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": "Fix the milk before the pattern: stretch for only 2–3 seconds with the wand tip just under the surface, then submerge and whirlpool until it sounds like tearing paper stops. Tap and swirl before pouring — paint, not bubble bath.",
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 (no ML, instant, CPU-friendly).
3
-
4
- Pipeline:
5
- 1. Find the cup (Hough circle, with a center-crop fallback).
6
- 2. Isolate the crema surface, segment milk foam vs crema (Otsu on lightness).
7
- 3. Compute five sub-scores in [0, 100]:
8
- contrast — separation between foam and crema tones (requires genuinely
9
- light foam, not just "lighter than the rest")
10
- symmetry — best mirror-axis IoU of the foam pattern (rotation-tolerant)
11
- centering — pattern centroid vs cup center
12
- definition — edge sharpness along the foam/crema boundary
13
- texture — milk quality: glossy microfoam scores high, visible
14
- bubbles / rough speckled foam scores low
15
- 4. A "presence" gate multiplies the total: almost-no-pattern or
16
- flooded-white cups can't ride individual metrics to a high score.
17
- 5. A power curve pushes mediocre pours down — championship scores must be earned.
18
-
19
- Main entry point: `score_image(path) -> dict`.
20
  """
21
 
22
  from __future__ import annotations
23
-
24
  import cv2
25
  import numpy as np
26
 
27
  WEIGHTS = {
28
- "contrast": 0.20,
29
- "symmetry": 0.25,
30
- "centering": 0.10,
31
- "definition": 0.25,
32
- "texture": 0.20,
33
  }
34
 
35
  MAX_SIDE = 720
36
- CURVE = 1.35 # >1 = harsher; total = 100 * (raw/100)^CURVE
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)), interpolation=cv2.INTER_AREA)
 
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, max_r = int(min(h, w) * 0.20), int(min(h, w) * 0.55)
 
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, surface: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
74
- """Return (raw, cleaned) foam masks. The raw-vs-clean difference measures speckle."""
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, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
 
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, foam: np.ndarray) -> float:
96
- """Tonal separation, gated by absolute foam lightness.
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()) # 0..255
109
- lightness = max(0.0, min(1.0, (foam_mean - 120.0) / 80.0)) # 1.0 at L>=200
110
  return _clamp((gap / 110.0) * lightness * 100.0)
111
 
112
 
113
- def _symmetry_score(foam: np.ndarray) -> float:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  if foam.sum() == 0:
115
  return 0.0
116
- ys, xs = np.nonzero(foam)
117
- pcx, pcy = float(xs.mean()), float(ys.mean())
118
- best = 0.0
119
- for angle in (-30, -15, 0, 15, 30):
120
- M = cv2.getRotationMatrix2D((pcx, pcy), angle, 1.0)
121
- rot = cv2.warpAffine(foam, M, (foam.shape[1], foam.shape[0]))
122
- shift = int(round(2 * pcx)) - rot.shape[1]
123
- flipped = cv2.flip(rot, 1)
124
- Mt = np.float32([[1, 0, shift], [0, 1, 0]])
125
- flipped = cv2.warpAffine(flipped, Mt, (rot.shape[1], rot.shape[0]))
126
- inter = np.logical_and(rot > 0, flipped > 0).sum()
127
- union = np.logical_or(rot > 0, flipped > 0).sum()
128
- if union > 0:
129
- best = max(best, inter / union)
130
- # Mirror IoU is naturally generous (blobs self-mirror well) — re-map so that
131
- # only genuinely tight symmetry reaches the top band.
132
- return _clamp(((best - 0.35) / 0.6) * 100.0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, cv2.CHAIN_APPROX_NONE)
 
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, foam_clean: np.ndarray) -> float:
161
- """Milk texture: glossy microfoam vs visible bubbles.
 
162
 
163
- Two signals, both inside the foam:
164
- roughness Laplacian energy in the foam interior (away from the pattern
165
- edge). Smooth paint-like microfoam is flat; bubbles are busy.
166
- speckle — how much of the raw Otsu mask was noise removed by the
167
- morphological clean-up. Bubbly foam segments as confetti.
168
  """
169
  if foam_clean.sum() == 0:
170
- return 0.0
171
- L = cv2.cvtColor(img, cv2.COLOR_BGR2LAB)[:, :, 0].astype(np.float32)
172
 
173
- interior = cv2.erode(foam_clean, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15)))
174
- rough_score = 50.0 # neutral if pattern too thin to have an interior
 
 
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
- # rough ~<4 = glossy, ~15+ = clearly bubbly
179
- rough_score = _clamp((1.0 - (rough - 4.0) / 14.0) * 100.0)
180
 
181
  diff = cv2.bitwise_xor(foam_raw, foam_clean)
182
  speckle = diff.sum() / max(foam_clean.sum(), 1)
183
- speckle_score = _clamp((1.0 - speckle / 0.18) * 100.0)
 
184
 
185
- return round(0.65 * rough_score + 0.35 * speckle_score, 1)
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": round(_contrast_score(img, surface, foam), 1),
217
- "symmetry": round(_symmetry_score(foam), 1),
218
- "centering": round(_centering_score(foam, cx, cy, r), 1),
219
  "definition": round(_definition_score(img, foam), 1),
220
- "texture": round(_texture_score(img, foam_raw, foam), 1),
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 # harshness 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))