RangGaraga commited on
Commit
deb84fb
Β·
verified Β·
1 Parent(s): 03134dd

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +707 -0
app.py ADDED
@@ -0,0 +1,707 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ╔══════════════════════════════════════════════════════════════╗
3
+ β•‘ SMILE SCORE β€” Diskon dengan Kemanisan Senyuman β•‘
4
+ β•‘ Model : Qwen/Qwen2.5-VL-3B-Instruct β•‘
5
+ β•‘ Space : RangGaraga/Smile_Score β•‘
6
+ β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•
7
+ """
8
+
9
+ import gradio as gr
10
+ import random
11
+ import re
12
+ import json
13
+ import torch
14
+ from PIL import Image
15
+ import numpy as np
16
+
17
+ from transformers import Qwen2_5_VLForConditionalGeneration, AutoProcessor
18
+ from qwen_vl_utils import process_vision_info
19
+
20
+ # ═══════════════════════════════════════════════════════════════════
21
+ # CONSTANTS
22
+ # ═══════════════════════════════════════════════════════════════════
23
+
24
+ BASE_PRICE = 6_000 # Rupiah
25
+
26
+ REWARD_TABLE = [
27
+ {"value": 0, "label": "Rp 0", "base_prob": 0.2000, "emoji": "😐"},
28
+ {"value": 1000, "label": "Rp 1.000", "base_prob": 0.4000, "emoji": "πŸ™‚"},
29
+ {"value": 2000, "label": "Rp 2.000", "base_prob": 0.3000, "emoji": "😊"},
30
+ {"value": 3000, "label": "Rp 3.000", "base_prob": 0.0990, "emoji": "😍"},
31
+ {"value": 6000, "label": "GRATIS", "base_prob": 0.0010, "emoji": "πŸŽ‰"},
32
+ ]
33
+
34
+ MODEL_ID = "Qwen/Qwen2.5-VL-3B-Instruct"
35
+
36
+ # ═══════════════════════════════════════════════════════════════════
37
+ # PROMPT ENGINEERING ← Rule-based logic ada di sini
38
+ # ═══════════════════════════════════════════════════════════════════
39
+
40
+ SMILE_SYSTEM_PROMPT = """Kamu adalah sistem AI penilai kemanisan senyuman untuk program diskon kafe.
41
+ Tugasmu adalah menganalisis foto wajah seseorang dan memberikan skor kemanisan senyuman mereka.
42
+
43
+ KRITERIA PENILAIAN (0–100):
44
+ 1. KETULUSAN β€” Apakah senyuman terlihat tulus dan alami? (bukan dipaksakan)
45
+ 2. MATA IKUT TERSENYUM β€” Efek Duchenne smile: sudut mata terangkat, ada kerutan halus di ujung mata
46
+ 3. SUDUT BIBIR β€” Seberapa terangkat sudut bibir kiri dan kanan
47
+ 4. KECERAHAN WAJAH β€” Pipi terangkat, wajah tampak hidup dan bersemangat
48
+ 5. GIGI TERLIHAT β€” Bonus poin jika senyuman sampai memperlihatkan gigi (opsional)
49
+ 6. ENERGI POSITIF β€” Keseluruhan aura kegembiraan yang terpancar dari ekspresi
50
+
51
+ SKALA SKOR:
52
+ 0 – 10 : Tidak ada senyuman sama sekali / ekspresi datar atau cemberut
53
+ 11 – 25 : Senyuman sangat tipis, hampir tidak terlihat
54
+ 26 – 45 : Senyuman ringan dan sopan, biasa-biasa saja
55
+ 46 – 65 : Senyuman yang cukup hangat dan menyenangkan
56
+ 66 – 80 : Senyuman manis dan tulus yang menawan
57
+ 81 – 90 : Senyuman sangat manis, tulus, dan penuh semangat
58
+ 91 – 100 : Senyuman luar biasa! Sangat manis, memukau, dan menular
59
+
60
+ ATURAN TAMBAHAN:
61
+ - Jika TIDAK ADA WAJAH dalam gambar, set has_face = false dan smile_score = 0
62
+ - Jika ada lebih dari satu wajah, nilai wajah yang paling menonjol / terdepan
63
+ - Jika gambar buram/gelap sehingga tidak bisa dinilai, berikan skor 0
64
+
65
+ RESPONS: Kembalikan HANYA JSON berikut, tanpa teks lain, tanpa markdown fence:
66
+ {"smile_score": <integer 0-100>, "description": "<kalimat singkat Bahasa Indonesia maks 60 kata>", "has_face": <true|false>}"""
67
+
68
+ # ═══════════════════════════════════════════════════════════════════
69
+ # MODEL LOADING
70
+ # ═══════════════════════════════════════════════════════════════════
71
+
72
+ print("πŸ”„ Memuat model Qwen2.5-VL-3B-Instruct…")
73
+ _dtype = torch.float16 if torch.cuda.is_available() else torch.float32
74
+ _device = "cuda" if torch.cuda.is_available() else "cpu"
75
+
76
+ model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
77
+ MODEL_ID,
78
+ torch_dtype=_dtype,
79
+ device_map="auto" if torch.cuda.is_available() else None,
80
+ trust_remote_code=True,
81
+ )
82
+ if not torch.cuda.is_available():
83
+ model = model.to(_device)
84
+ model.eval()
85
+
86
+ processor = AutoProcessor.from_pretrained(MODEL_ID, trust_remote_code=True)
87
+ print("βœ… Model siap!")
88
+
89
+ # ═══════════════════════════════════════════════════════════════��═══
90
+ # CORE LOGIC
91
+ # ═══════════════════════════════════════════════════════════════════
92
+
93
+ def analyze_smile(pil_image: Image.Image) -> tuple[int, str, bool]:
94
+ """Run VLM inference and extract smile score."""
95
+ messages = [
96
+ {
97
+ "role": "user",
98
+ "content": [
99
+ {"type": "image", "image": pil_image},
100
+ {"type": "text", "text": SMILE_SYSTEM_PROMPT},
101
+ ],
102
+ }
103
+ ]
104
+
105
+ text_prompt = processor.apply_chat_template(
106
+ messages, tokenize=False, add_generation_prompt=True
107
+ )
108
+ image_inputs, video_inputs = process_vision_info(messages)
109
+
110
+ inputs = processor(
111
+ text=[text_prompt],
112
+ images=image_inputs,
113
+ videos=video_inputs,
114
+ padding=True,
115
+ return_tensors="pt",
116
+ ).to(next(model.parameters()).device)
117
+
118
+ with torch.no_grad():
119
+ gen_ids = model.generate(
120
+ **inputs,
121
+ max_new_tokens=200,
122
+ temperature=0.05,
123
+ do_sample=False,
124
+ repetition_penalty=1.05,
125
+ )
126
+
127
+ trimmed = [
128
+ out[len(inp):]
129
+ for inp, out in zip(inputs.input_ids, gen_ids)
130
+ ]
131
+ raw_output = processor.batch_decode(
132
+ trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False
133
+ )[0].strip()
134
+
135
+ # ── Parse JSON ──────────────────────────────────────────────
136
+ json_match = re.search(r"\{.*?\}", raw_output, re.DOTALL)
137
+ if json_match:
138
+ try:
139
+ data = json.loads(json_match.group())
140
+ score = max(0, min(100, int(data.get("smile_score", 0))))
141
+ desc = data.get("description", "Senyuman berhasil dianalisis.")
142
+ has_face = bool(data.get("has_face", True))
143
+ return score, desc, has_face
144
+ except (json.JSONDecodeError, ValueError):
145
+ pass
146
+
147
+ # Fallback: cari angka 0–100 pertama
148
+ nums = re.findall(r"\b(\d{1,3})\b", raw_output)
149
+ for n in nums:
150
+ if 0 <= int(n) <= 100:
151
+ return int(n), raw_output[:120], True
152
+
153
+ return 0, "Tidak dapat menganalisis senyuman.", False
154
+
155
+
156
+ def get_minimum_reward(smile_score: int) -> int:
157
+ """
158
+ Rule: Skor senyuman tinggi MENJAMIN minimum diskon tertentu.
159
+ Mencegah hasil terasa tidak adil (senyum manis tapi dapat Rp 0).
160
+ 0 - 30 β†’ Rp 0 (murni gacha)
161
+ 31 - 80 β†’ min Rp 1.000
162
+ 81 - 100 β†’ min Rp 2.000
163
+ """
164
+ if smile_score >= 81:
165
+ return 2000
166
+ elif smile_score >= 31:
167
+ return 1000
168
+ else:
169
+ return 0
170
+
171
+
172
+ def calculate_reward(smile_score: int) -> tuple[int, list[float]]:
173
+ """
174
+ Rule-based probability adjustment:
175
+ 1. Skor tinggi geser peluang ke reward yang lebih besar.
176
+ 2. Minimum reward guarantee agar hasil terasa adil dan konsisten.
177
+ """
178
+ boost = smile_score / 100.0 # 0.0 - 1.0
179
+
180
+ # Probability shifting rules
181
+ w = [
182
+ max(0.03, REWARD_TABLE[0]["base_prob"] - boost * 0.17), # 0 down
183
+ max(0.10, REWARD_TABLE[1]["base_prob"] - boost * 0.10), # 1000 slightly down
184
+ REWARD_TABLE[2]["base_prob"] + boost * 0.05, # 2000 up
185
+ REWARD_TABLE[3]["base_prob"] + boost * 0.10, # 3000 up
186
+ REWARD_TABLE[4]["base_prob"] + boost * 0.12, # GRATIS up significantly
187
+ ]
188
+
189
+ total = sum(w)
190
+ w = [x / total for x in w]
191
+
192
+ chosen = random.choices(
193
+ [r["value"] for r in REWARD_TABLE],
194
+ weights=w, k=1
195
+ )[0]
196
+
197
+ # Minimum reward guarantee (rule override)
198
+ min_reward = get_minimum_reward(smile_score)
199
+ if chosen < min_reward:
200
+ chosen = min_reward
201
+ # Zero-out tiers below minimum for display accuracy
202
+ for i, r in enumerate(REWARD_TABLE):
203
+ if r["value"] < min_reward:
204
+ w[i] = 0.0
205
+ total = sum(w)
206
+ w = [x / total if total > 0 else 0.0 for x in w]
207
+
208
+ return chosen, w
209
+
210
+
211
+ def fmt_rp(amount: int) -> str:
212
+ return f"Rp {amount:,}".replace(",", ".")
213
+
214
+
215
+ # ═══════════════════════════════════════════════════════════════════
216
+ # GRADIO HANDLER
217
+ # ═══════════════════════════════════════════════════════════════════
218
+
219
+ def run_analysis(image):
220
+ """Main callback untuk tombol Analisis."""
221
+ if image is None:
222
+ return (
223
+ _error_card("Belum ada gambar! Upload foto atau ambil selfie dulu ya. πŸ“·"),
224
+ 0,
225
+ gr.update(visible=False),
226
+ )
227
+
228
+ if isinstance(image, np.ndarray):
229
+ pil = Image.fromarray(image.astype(np.uint8))
230
+ else:
231
+ pil = image
232
+
233
+ smile_score, description, has_face = analyze_smile(pil)
234
+
235
+ if not has_face:
236
+ return (
237
+ _error_card("Wajah tidak terdeteksi. Pastikan wajahmu terlihat jelas di foto! 🀳"),
238
+ 0,
239
+ gr.update(visible=False),
240
+ )
241
+
242
+ reward, adj_weights = calculate_reward(smile_score)
243
+ final_price = max(0, BASE_PRICE - reward)
244
+ is_free = (reward >= BASE_PRICE)
245
+
246
+ result_html = _build_result_html(
247
+ smile_score, description, reward, adj_weights, final_price, is_free
248
+ )
249
+
250
+ return result_html, smile_score, gr.update(visible=True)
251
+
252
+
253
+ def _error_card(msg: str) -> str:
254
+ return f"""
255
+ <div style="
256
+ background: linear-gradient(135deg, #ff6b6b22, #ff6b6b11);
257
+ border: 1.5px solid #ff6b6b66;
258
+ border-radius: 16px;
259
+ padding: 24px 28px;
260
+ font-family: 'Nunito', sans-serif;
261
+ color: #ff4444;
262
+ text-align: center;
263
+ font-size: 1.05rem;
264
+ ">⚠️ {msg}</div>"""
265
+
266
+
267
+ def _smile_bar_html(score: int) -> str:
268
+ color = (
269
+ "#ff6b6b" if score < 30 else
270
+ "#ffd166" if score < 55 else
271
+ "#06d6a0" if score < 75 else
272
+ "#f72585"
273
+ )
274
+ emoji = (
275
+ "😐" if score < 20 else
276
+ "πŸ™‚" if score < 40 else
277
+ "😊" if score < 60 else
278
+ "😍" if score < 80 else
279
+ "🌟"
280
+ )
281
+ return f"""
282
+ <div style="margin: 12px 0;">
283
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;">
284
+ <span style="font-weight:700;font-size:1.1rem;color:#333;">Skor Kemanisan Senyuman</span>
285
+ <span style="font-size:1.6rem;">{emoji} <strong style="color:{color};font-size:1.3rem;">{score}</strong><span style="color:#888;font-size:0.9rem;">/100</span></span>
286
+ </div>
287
+ <div style="background:#e8e8e8;border-radius:99px;height:14px;overflow:hidden;">
288
+ <div style="width:{score}%;height:100%;background:linear-gradient(90deg,{color}88,{color});border-radius:99px;transition:width 1s ease;"></div>
289
+ </div>
290
+ </div>"""
291
+
292
+
293
+ def _prob_rows_html(adj_weights: list[float], chosen_value: int) -> str:
294
+ rows = ""
295
+ for r, w in zip(REWARD_TABLE, adj_weights):
296
+ is_winner = (r["value"] == chosen_value)
297
+ bg = "background:linear-gradient(90deg,#f72585,#b5179e);color:#fff;font-weight:800;" if is_winner else ""
298
+ badge = " ← Kamu! 🎯" if is_winner else ""
299
+ rows += f"""
300
+ <tr style="{bg}border-radius:8px;">
301
+ <td style="padding:10px 14px;border-radius:8px 0 0 8px;">{r['emoji']} {r['label']}</td>
302
+ <td style="padding:10px 14px;text-align:center;">{w*100:.2f}%</td>
303
+ <td style="padding:10px 14px;border-radius:0 8px 8px 0;">{badge}</td>
304
+ </tr>"""
305
+ return rows
306
+
307
+
308
+ def _build_result_html(
309
+ smile_score: int,
310
+ description: str,
311
+ reward: int,
312
+ adj_weights: list[float],
313
+ final_price: int,
314
+ is_free: bool,
315
+ ) -> str:
316
+
317
+ prob_rows = _prob_rows_html(adj_weights, reward)
318
+
319
+ if is_free:
320
+ price_display = "🎊 G R A T I S 🎊"
321
+ final_style = "color:#f72585;font-size:1.6rem;font-weight:900;"
322
+ banner = f"""<div style="background:linear-gradient(135deg,#f72585,#b5179e,#7209b7);border-radius:16px;padding:20px;text-align:center;color:#fff;margin-bottom:20px;animation:pulse 1s infinite;">
323
+ <div style="font-size:2.5rem;">πŸŽŠπŸŽ‰πŸŽŠ</div>
324
+ <div style="font-size:1.4rem;font-weight:900;letter-spacing:2px;">SELAMAT! KAMU DAPAT GRATIS!</div>
325
+ <div style="font-size:0.9rem;opacity:0.9;margin-top:4px;">Senyumanmu luar biasa! Sangat jarang terjadi (0.01%)</div>
326
+ </div>"""
327
+ else:
328
+ price_display = fmt_rp(final_price)
329
+ final_style = "color:#2d3436;font-size:1.4rem;font-weight:800;"
330
+ banner = ""
331
+ if reward >= 3000:
332
+ banner = """<div style="background:linear-gradient(135deg,#f72585,#480ca8);border-radius:16px;padding:14px;text-align:center;color:#fff;margin-bottom:16px;">
333
+ <span style="font-size:1.2rem;font-weight:700;">πŸ₯³ Wow! Diskon Besar! Senyumanmu sangat manis!</span>
334
+ </div>"""
335
+
336
+ discount_label = "🎁 GRATIS" if is_free else fmt_rp(reward)
337
+
338
+ return f"""
339
+ <div style="font-family:'Nunito',sans-serif;max-width:100%;">
340
+
341
+ {banner}
342
+
343
+ <div style="background:#fff;border-radius:20px;padding:24px;box-shadow:0 4px 24px #0001;margin-bottom:16px;">
344
+ <h3 style="margin:0 0 12px;color:#333;font-size:1.15rem;">✨ Analisis Senyuman</h3>
345
+ {_smile_bar_html(smile_score)}
346
+ <p style="background:#f8f8f8;border-left:4px solid #f72585;padding:10px 14px;border-radius:0 10px 10px 0;margin:12px 0 0;font-style:italic;color:#555;font-size:0.95rem;">"{description}"</p>
347
+ </div>
348
+
349
+ <div style="background:#fff;border-radius:20px;padding:24px;box-shadow:0 4px 24px #0001;margin-bottom:16px;">
350
+ <h3 style="margin:0 0 14px;color:#333;font-size:1.15rem;">🎰 Peluang Diskon (Disesuaikan Senyumanmu)</h3>
351
+ <table style="width:100%;border-collapse:separate;border-spacing:0 4px;font-size:0.95rem;">
352
+ <thead>
353
+ <tr style="color:#888;font-size:0.82rem;text-transform:uppercase;letter-spacing:0.5px;">
354
+ <th style="padding:4px 14px;text-align:left;">Reward</th>
355
+ <th style="padding:4px 14px;text-align:center;">Peluang</th>
356
+ <th style="padding:4px 14px;"></th>
357
+ </tr>
358
+ </thead>
359
+ <tbody>
360
+ {prob_rows}
361
+ </tbody>
362
+ </table>
363
+ </div>
364
+
365
+ <div style="background:linear-gradient(135deg,#2d3436,#636e72);border-radius:20px;padding:24px;color:#fff;box-shadow:0 4px 24px #0003;">
366
+ <h3 style="margin:0 0 16px;font-size:1.15rem;">🏷️ Ringkasan Pembelian</h3>
367
+ <div style="display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid #ffffff22;">
368
+ <span style="color:#ccc;">Harga Normal</span>
369
+ <span style="font-weight:600;">{fmt_rp(BASE_PRICE)}</span>
370
+ </div>
371
+ <div style="display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid #ffffff22;">
372
+ <span style="color:#ccc;">Diskon Senyuman 🎁</span>
373
+ <span style="font-weight:700;color:#f72585;">- {discount_label}</span>
374
+ </div>
375
+ <div style="display:flex;justify-content:space-between;padding:14px 0 0;">
376
+ <span style="font-size:1.1rem;font-weight:700;">πŸ’° Harga Final</span>
377
+ <span style="{final_style}">{price_display}</span>
378
+ </div>
379
+ </div>
380
+
381
+ </div>
382
+ <style>
383
+ @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800;900&display=swap');
384
+ @keyframes pulse {{0%,100%{{opacity:1}}50%{{opacity:.7}}}}
385
+ </style>"""
386
+
387
+
388
+ # ═══════════════════════════════════════════════════════════════════
389
+ # CUSTOM CSS
390
+ # ═══════════════════════════════════════════════════════════════════
391
+
392
+ CUSTOM_CSS = """
393
+ @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800;900&family=Pacifico&display=swap');
394
+
395
+ :root {
396
+ --color-primary: #f72585;
397
+ --color-secondary: #7209b7;
398
+ --color-accent: #4cc9f0;
399
+ --color-bg: #fff9fb;
400
+ --color-card: #ffffff;
401
+ --radius: 20px;
402
+ }
403
+
404
+ body, .gradio-container {
405
+ font-family: 'Nunito', sans-serif !important;
406
+ background: var(--color-bg) !important;
407
+ }
408
+
409
+ /* ── Header ── */
410
+ .smile-header {
411
+ text-align: center;
412
+ padding: 32px 16px 20px;
413
+ }
414
+ .smile-header .big-title {
415
+ font-family: 'Pacifico', cursive;
416
+ font-size: clamp(2rem, 5vw, 3.2rem);
417
+ background: linear-gradient(135deg, #f72585, #b5179e, #7209b7);
418
+ -webkit-background-clip: text;
419
+ -webkit-text-fill-color: transparent;
420
+ background-clip: text;
421
+ margin: 0;
422
+ line-height: 1.1;
423
+ }
424
+ .smile-header .subtitle {
425
+ color: #888;
426
+ font-size: 1rem;
427
+ margin: 6px 0 0;
428
+ }
429
+
430
+ /* ── Tabs ── */
431
+ .tab-nav button {
432
+ font-family: 'Nunito', sans-serif !important;
433
+ font-weight: 700 !important;
434
+ font-size: 1rem !important;
435
+ border-radius: 99px !important;
436
+ padding: 8px 24px !important;
437
+ transition: all .2s !important;
438
+ }
439
+ .tab-nav button.selected {
440
+ background: linear-gradient(135deg, #f72585, #7209b7) !important;
441
+ color: #fff !important;
442
+ box-shadow: 0 4px 16px #f7258544 !important;
443
+ }
444
+
445
+ /* ── Main analyze button ── */
446
+ .btn-analyze {
447
+ background: linear-gradient(135deg, #f72585, #b5179e, #7209b7) !important;
448
+ color: #fff !important;
449
+ font-family: 'Nunito', sans-serif !important;
450
+ font-weight: 800 !important;
451
+ font-size: 1.15rem !important;
452
+ border-radius: 99px !important;
453
+ padding: 14px 0 !important;
454
+ border: none !important;
455
+ cursor: pointer !important;
456
+ transition: transform .15s, box-shadow .15s !important;
457
+ box-shadow: 0 6px 24px #f7258555 !important;
458
+ width: 100%;
459
+ }
460
+ .btn-analyze:hover {
461
+ transform: translateY(-2px) !important;
462
+ box-shadow: 0 10px 32px #f7258577 !important;
463
+ }
464
+ .btn-analyze:active { transform: translateY(0) !important; }
465
+
466
+ /* ── Reset button ── */
467
+ .btn-reset {
468
+ background: #f0f0f0 !important;
469
+ color: #555 !important;
470
+ font-family: 'Nunito', sans-serif !important;
471
+ font-weight: 700 !important;
472
+ border-radius: 99px !important;
473
+ border: none !important;
474
+ cursor: pointer !important;
475
+ transition: background .15s !important;
476
+ width: 100%;
477
+ }
478
+ .btn-reset:hover { background: #e0e0e0 !important; }
479
+
480
+ /* ── Cards ── */
481
+ .info-card {
482
+ background: linear-gradient(135deg, #f72585, #7209b7);
483
+ border-radius: var(--radius);
484
+ padding: 20px 24px;
485
+ color: #fff;
486
+ margin-bottom: 16px;
487
+ }
488
+ .info-card h4 { margin: 0 0 10px; font-size: 1rem; opacity: .85; font-weight: 700; }
489
+ .info-card .price-big { font-size: 2rem; font-weight: 900; }
490
+
491
+ /* ── Reward pills ── */
492
+ .reward-pill {
493
+ display: inline-block;
494
+ padding: 4px 14px;
495
+ border-radius: 99px;
496
+ font-weight: 700;
497
+ font-size: 0.88rem;
498
+ background: #f72585;
499
+ color: #fff;
500
+ margin: 3px;
501
+ }
502
+
503
+ /* ── Score progress bar (Gradio slider overrides) ── */
504
+ #smile-score-slider .wrap { pointer-events: none; }
505
+ #smile-score-slider input[type=range] {
506
+ accent-color: #f72585;
507
+ }
508
+
509
+ /* ── Result panel ── */
510
+ #result-panel .prose { font-family: 'Nunito', sans-serif !important; }
511
+
512
+ /* ── Image areas ── */
513
+ .image-upload-area {
514
+ border: 2.5px dashed #f7258577 !important;
515
+ border-radius: var(--radius) !important;
516
+ background: #fff9fb !important;
517
+ min-height: 260px;
518
+ }
519
+
520
+ /* ── Mirror webcam: flip video feed horizontally ── */
521
+ #webcam-mirror video,
522
+ #webcam-mirror canvas {
523
+ transform: scaleX(-1) !important;
524
+ }
525
+ """
526
+
527
+ # ═══════════════════════════════════════════════════════════════════
528
+ # GRADIO UI
529
+ # ═══════════════════════════════════════════════════════════════════
530
+
531
+ def reset_ui():
532
+ return None, None, "", 0, gr.update(visible=False)
533
+
534
+ _theme = gr.themes.Base(
535
+ font=gr.themes.GoogleFont("Nunito"),
536
+ primary_hue=gr.themes.colors.pink,
537
+ secondary_hue=gr.themes.colors.purple,
538
+ neutral_hue=gr.themes.colors.gray,
539
+ radius_size=gr.themes.sizes.radius_lg,
540
+ )
541
+
542
+ with gr.Blocks(title="😊 Smile Score β€” Diskon dengan Senyuman") as demo:
543
+
544
+ # ── Header ─────────────────────────────────────────────────
545
+ gr.HTML("""
546
+ <div class="smile-header">
547
+ <h1 class="big-title">😊 Smile Score</h1>
548
+ <p class="subtitle">Senyum lebih manis β†’ Diskon lebih besar! Harga normal <strong>Rp 6.000</strong></p>
549
+ <div style="margin-top:12px;display:flex;flex-wrap:wrap;justify-content:center;gap:8px;">
550
+ <span class="reward-pill">😐 Rp 0 (20%)</span>
551
+ <span class="reward-pill">πŸ™‚ Rp 1.000 (40%)</span>
552
+ <span class="reward-pill">😊 Rp 2.000 (30%)</span>
553
+ <span class="reward-pill">😍 Rp 3.000 (9.9%)</span>
554
+ <span class="reward-pill">πŸŽ‰ GRATIS (0.01%)</span>
555
+ </div>
556
+ </div>
557
+ """)
558
+
559
+ # ── Main Layout ────────────────────────────────────────────
560
+ with gr.Row(equal_height=False):
561
+
562
+ # LEFT COLUMN β€” Input
563
+ with gr.Column(scale=1, min_width=300):
564
+
565
+ with gr.Tabs(elem_classes="tab-nav") as input_tabs:
566
+
567
+ with gr.TabItem("πŸ“· Selfie (Webcam)", id="tab_webcam"):
568
+ webcam_input = gr.Image(
569
+ sources="webcam",
570
+ type="numpy",
571
+ label="Ambil Selfie",
572
+ elem_id="webcam-mirror",
573
+ elem_classes="image-upload-area",
574
+ height=300,
575
+ )
576
+ gr.HTML("<p style='text-align:center;color:#aaa;font-size:0.85rem;margin-top:6px;'>Pastikan wajahmu terlihat jelas & tersenyum manis! 😊</p>")
577
+
578
+ with gr.TabItem("πŸ–ΌοΈ Upload Foto", id="tab_upload"):
579
+ upload_input = gr.Image(
580
+ sources=["upload", "clipboard"],
581
+ type="numpy",
582
+ label="Upload Foto",
583
+ elem_classes="image-upload-area",
584
+ height=300,
585
+ )
586
+ gr.HTML("<p style='text-align:center;color:#aaa;font-size:0.85rem;margin-top:6px;'>Format JPG / PNG / WEBP. Max 10MB.</p>")
587
+
588
+ gr.HTML("<div style='height:12px;'></div>")
589
+
590
+ analyze_btn = gr.Button(
591
+ "✨ Analisis Senyumanku!",
592
+ elem_classes="btn-analyze",
593
+ variant="primary",
594
+ )
595
+
596
+ reset_btn = gr.Button(
597
+ "πŸ”„ Reset",
598
+ elem_classes="btn-reset",
599
+ variant="secondary",
600
+ )
601
+
602
+ # Info card: harga pokok
603
+ gr.HTML("""
604
+ <div class="info-card" style="margin-top:16px;">
605
+ <h4>🏷️ Harga Normal Produk</h4>
606
+ <div class="price-big">Rp 6.000</div>
607
+ <div style="font-size:0.88rem;opacity:.8;margin-top:4px;">
608
+ Senyum tulus = peluang diskon makin besar!
609
+ </div>
610
+ </div>
611
+ """)
612
+
613
+ # Tips
614
+ with gr.Accordion("πŸ’‘ Tips Senyuman Manis", open=False):
615
+ gr.HTML("""
616
+ <ul style="font-size:0.92rem;color:#555;line-height:1.8;margin:0;padding-left:18px;">
617
+ <li>Biarkan senyumanmu <strong>alami & tulus</strong></li>
618
+ <li>Ikutkan <strong>matamu tersenyum</strong> (efek Duchenne)</li>
619
+ <li>Tampilkan <strong>gigi</strong> untuk nilai lebih</li>
620
+ <li>Pastikan <strong>pencahayaan cukup</strong> & wajah jelas</li>
621
+ <li>Bayangkan hal yang membuatmu <strong>bahagia!</strong> 😊</li>
622
+ </ul>
623
+ """)
624
+
625
+ # RIGHT COLUMN β€” Output
626
+ with gr.Column(scale=1, min_width=320):
627
+
628
+ gr.HTML("<h3 style='margin:0 0 12px;font-family:Nunito,sans-serif;color:#333;'>πŸ“Š Hasil Analisis</h3>")
629
+
630
+ smile_score_slider = gr.Slider(
631
+ minimum=0, maximum=100, value=0,
632
+ label="Skor Senyuman",
633
+ interactive=False,
634
+ elem_id="smile-score-slider",
635
+ )
636
+
637
+ result_html = gr.HTML(
638
+ value="""<div style="background:#f9f9f9;border-radius:20px;padding:40px 24px;text-align:center;color:#bbb;font-family:Nunito,sans-serif;">
639
+ <div style='font-size:3rem;'>😢</div>
640
+ <div style='margin-top:8px;font-size:1rem;'>Ambil selfie atau upload foto,<br>lalu klik <strong>Analisis Senyumanku!</strong></div>
641
+ </div>""",
642
+ elem_id="result-panel",
643
+ )
644
+
645
+ again_btn = gr.Button(
646
+ "πŸ” Coba Lagi!",
647
+ elem_classes="btn-analyze",
648
+ visible=False,
649
+ variant="primary",
650
+ )
651
+
652
+ # ── Footer ─────────────────────────────────────────────────
653
+ gr.HTML("""
654
+ <div style="text-align:center;padding:28px 16px 12px;color:#ccc;font-family:Nunito,sans-serif;font-size:0.85rem;">
655
+ Powered by <strong style="color:#f72585;">Qwen2.5-VL-3B-Instruct</strong> Β·
656
+ Built with ❀️ on <strong>Hugging Face Spaces</strong>
657
+ </div>
658
+ """)
659
+
660
+ # ── State & Events ─────────────────────────────────────────
661
+
662
+ active_image = gr.State(None)
663
+
664
+ def merge_inputs(webcam_img, upload_img):
665
+ """Gunakan gambar dari tab yang aktif (yang tidak None)."""
666
+ img = webcam_img if webcam_img is not None else upload_img
667
+ return img
668
+
669
+ def on_analyze(webcam_img, upload_img):
670
+ img = merge_inputs(webcam_img, upload_img)
671
+ html, score, again_vis = run_analysis(img)
672
+ return html, score, again_vis
673
+
674
+ analyze_btn.click(
675
+ fn=on_analyze,
676
+ inputs=[webcam_input, upload_input],
677
+ outputs=[result_html, smile_score_slider, again_btn],
678
+ )
679
+
680
+ again_btn.click(
681
+ fn=on_analyze,
682
+ inputs=[webcam_input, upload_input],
683
+ outputs=[result_html, smile_score_slider, again_btn],
684
+ )
685
+
686
+ reset_btn.click(
687
+ fn=lambda: (
688
+ None, None,
689
+ """<div style="background:#f9f9f9;border-radius:20px;padding:40px 24px;text-align:center;color:#bbb;font-family:Nunito,sans-serif;">
690
+ <div style='font-size:3rem;'>😢</div>
691
+ <div style='margin-top:8px;font-size:1rem;'>Ambil selfie atau upload foto,<br>lalu klik <strong>Analisis Senyumanku!</strong></div>
692
+ </div>""",
693
+ 0,
694
+ gr.update(visible=False),
695
+ ),
696
+ inputs=[],
697
+ outputs=[webcam_input, upload_input, result_html, smile_score_slider, again_btn],
698
+ )
699
+
700
+
701
+ demo.launch(
702
+ server_name="0.0.0.0",
703
+ server_port=7860,
704
+ share=False,
705
+ css=CUSTOM_CSS,
706
+ theme=_theme,
707
+ )