Spaces:
Runtime error
Runtime error
| import re | |
| import json | |
| import torch | |
| import random | |
| import numpy as np | |
| import gradio as gr | |
| from PIL import Image | |
| from qwen_vl_utils import process_vision_info | |
| from transformers import Qwen2_5_VLForConditionalGeneration, AutoProcessor | |
| # CONSTANTS | |
| BASE_PRICE = 6_000 # Rupiah | |
| REWARD_TABLE = [ | |
| {"value": 0, "label": "Rp 0", "base_prob": 0.2000, "emoji": "😐"}, | |
| {"value": 1000, "label": "Rp 1.000", "base_prob": 0.4000, "emoji": "🙂"}, | |
| {"value": 2000, "label": "Rp 2.000", "base_prob": 0.3000, "emoji": "😊"}, | |
| {"value": 3000, "label": "Rp 3.000", "base_prob": 0.0990, "emoji": "😍"}, | |
| {"value": 6000, "label": "GRATIS", "base_prob": 0.0010, "emoji": "🎉"}, | |
| ] | |
| MODEL_ID = "Qwen/Qwen2.5-VL-3B-Instruct" | |
| # PROMPT ENGINEERING | |
| SMILE_SYSTEM_PROMPT = """Kamu adalah sistem AI penilai kemanisan senyuman untuk program diskon kafe. | |
| Tugasmu adalah menganalisis foto wajah seseorang dan memberikan skor kemanisan senyuman mereka. | |
| KRITERIA PENILAIAN (0–100): | |
| 1. KETULUSAN — Apakah senyuman terlihat tulus dan alami? (bukan dipaksakan) | |
| 2. MATA IKUT TERSENYUM — Efek Duchenne smile: sudut mata terangkat, ada kerutan halus di ujung mata | |
| 3. SUDUT BIBIR — Seberapa terangkat sudut bibir kiri dan kanan | |
| 4. KECERAHAN WAJAH — Pipi terangkat, wajah tampak hidup dan bersemangat | |
| 5. GIGI TERLIHAT — Bonus poin jika senyuman sampai memperlihatkan gigi (opsional) | |
| 6. ENERGI POSITIF — Keseluruhan aura kegembiraan yang terpancar dari ekspresi | |
| SKALA SKOR: | |
| 0 – 10 : Tidak ada senyuman sama sekali / ekspresi datar atau cemberut | |
| 11 – 25 : Senyuman sangat tipis, hampir tidak terlihat | |
| 26 – 45 : Senyuman ringan dan sopan, biasa-biasa saja | |
| 46 – 65 : Senyuman yang cukup hangat dan menyenangkan | |
| 66 – 80 : Senyuman manis dan tulus yang menawan | |
| 81 – 90 : Senyuman sangat manis, tulus, dan penuh semangat | |
| 91 – 100 : Senyuman luar biasa! Sangat manis, memukau, dan menular | |
| ATURAN TAMBAHAN: | |
| - Jika TIDAK ADA WAJAH dalam gambar, set has_face = false dan smile_score = 0 | |
| - Jika ada lebih dari satu wajah, nilai wajah yang paling menonjol / terdepan | |
| - Jika gambar buram/gelap sehingga tidak bisa dinilai, berikan skor 0 | |
| RESPONS: Kembalikan HANYA JSON berikut, tanpa teks lain, tanpa markdown fence: | |
| {"smile_score": <integer 0-100>, "description": "<kalimat singkat Bahasa Indonesia maks 60 kata>", "has_face": <true|false>}""" | |
| # MODEL LOADING | |
| print("Memuat model Qwen2.5-VL-3B-Instruct...") | |
| _dtype = torch.float16 if torch.cuda.is_available() else torch.float32 | |
| _device = "cuda" if torch.cuda.is_available() else "cpu" | |
| model = Qwen2_5_VLForConditionalGeneration.from_pretrained( | |
| MODEL_ID, | |
| torch_dtype=_dtype, | |
| device_map="auto" if torch.cuda.is_available() else None, | |
| trust_remote_code=True, | |
| ) | |
| if not torch.cuda.is_available(): | |
| model = model.to(_device) | |
| model.eval() | |
| processor = AutoProcessor.from_pretrained(MODEL_ID, trust_remote_code=True) | |
| print("Model siap!") | |
| # CORE LOGIC | |
| def analyze_smile(pil_image: Image.Image) -> tuple[int, str, bool]: | |
| """Run VLM inference and extract smile score.""" | |
| messages = [ | |
| { | |
| "role": "user", | |
| "content": [ | |
| {"type": "image", "image": pil_image}, | |
| {"type": "text", "text": SMILE_SYSTEM_PROMPT}, | |
| ], | |
| } | |
| ] | |
| text_prompt = processor.apply_chat_template( | |
| messages, tokenize=False, add_generation_prompt=True | |
| ) | |
| image_inputs, video_inputs = process_vision_info(messages) | |
| inputs = processor( | |
| text=[text_prompt], | |
| images=image_inputs, | |
| videos=video_inputs, | |
| padding=True, | |
| return_tensors="pt", | |
| ).to(next(model.parameters()).device) | |
| with torch.no_grad(): | |
| gen_ids = model.generate( | |
| **inputs, | |
| max_new_tokens=200, | |
| temperature=0.05, | |
| do_sample=False, | |
| repetition_penalty=1.05, | |
| ) | |
| trimmed = [ | |
| out[len(inp):] | |
| for inp, out in zip(inputs.input_ids, gen_ids) | |
| ] | |
| raw_output = processor.batch_decode( | |
| trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False | |
| )[0].strip() | |
| # Parse JSON | |
| json_match = re.search(r"\{.*?\}", raw_output, re.DOTALL) | |
| if json_match: | |
| try: | |
| data = json.loads(json_match.group()) | |
| score = max(0, min(100, int(data.get("smile_score", 0)))) | |
| desc = data.get("description", "Senyuman berhasil dianalisis.") | |
| has_face = bool(data.get("has_face", True)) | |
| return score, desc, has_face | |
| except (json.JSONDecodeError, ValueError): | |
| pass | |
| # Fallback: cari angka 0–100 pertama | |
| nums = re.findall(r"\b(\d{1,3})\b", raw_output) | |
| for n in nums: | |
| if 0 <= int(n) <= 100: | |
| return int(n), raw_output[:120], True | |
| return 0, "Tidak dapat menganalisis senyuman.", False | |
| def get_minimum_reward(smile_score: int) -> int: | |
| """ | |
| Rule: Skor senyuman tinggi MENJAMIN minimum diskon tertentu. | |
| Mencegah hasil terasa tidak adil (senyum manis tapi dapat Rp 0). | |
| 0 - 30 → Rp 0 (murni gacha) | |
| 31 - 80 → min Rp 1.000 | |
| 81 - 100 → min Rp 2.000 | |
| """ | |
| if smile_score >= 81: | |
| return 2000 | |
| elif smile_score >= 31: | |
| return 1000 | |
| else: | |
| return 0 | |
| def calculate_reward(smile_score: int) -> tuple[int, list[float]]: | |
| boost = smile_score / 100.0 # 0.0 - 1.0 | |
| # Probability shifting rules | |
| w = [ | |
| max(0.03, REWARD_TABLE[0]["base_prob"] - boost * 0.17), # 0 down | |
| max(0.10, REWARD_TABLE[1]["base_prob"] - boost * 0.10), # 1000 slightly down | |
| REWARD_TABLE[2]["base_prob"] + boost * 0.05, # 2000 up | |
| REWARD_TABLE[3]["base_prob"] + boost * 0.10, # 3000 up | |
| REWARD_TABLE[4]["base_prob"] + boost * 0.12, # GRATIS up significantly | |
| ] | |
| total = sum(w) | |
| w = [x / total for x in w] | |
| chosen = random.choices( | |
| [r["value"] for r in REWARD_TABLE], | |
| weights=w, k=1 | |
| )[0] | |
| # Minimum reward guarantee (rule override) | |
| min_reward = get_minimum_reward(smile_score) | |
| if chosen < min_reward: | |
| chosen = min_reward | |
| # Zero-out tiers below minimum for display accuracy | |
| for i, r in enumerate(REWARD_TABLE): | |
| if r["value"] < min_reward: | |
| w[i] = 0.0 | |
| total = sum(w) | |
| w = [x / total if total > 0 else 0.0 for x in w] | |
| return chosen, w | |
| def fmt_rp(amount: int) -> str: | |
| return f"Rp {amount:,}".replace(",", ".") | |
| # GRADIO HANDLER | |
| def run_analysis(image): | |
| """Main callback untuk tombol Analisis.""" | |
| if image is None: | |
| return ( | |
| _error_card("Belum ada gambar! Upload foto atau ambil selfie dulu ya."), | |
| 0, | |
| gr.update(visible=False), | |
| ) | |
| if isinstance(image, np.ndarray): | |
| pil = Image.fromarray(image.astype(np.uint8)) | |
| else: | |
| pil = image | |
| smile_score, description, has_face = analyze_smile(pil) | |
| if not has_face: | |
| return ( | |
| _error_card("Wajah tidak terdeteksi. Pastikan wajahmu terlihat jelas di foto!"), | |
| 0, | |
| gr.update(visible=False), | |
| ) | |
| reward, adj_weights = calculate_reward(smile_score) | |
| final_price = max(0, BASE_PRICE - reward) | |
| is_free = (reward >= BASE_PRICE) | |
| result_html = _build_result_html( | |
| smile_score, description, reward, adj_weights, final_price, is_free | |
| ) | |
| return result_html, smile_score, gr.update(visible=True) | |
| def _error_card(msg: str) -> str: | |
| return f""" | |
| <div style=" | |
| background: linear-gradient(135deg, #ff6b6b22, #ff6b6b11); | |
| border: 1.5px solid #ff6b6b66; | |
| border-radius: 16px; | |
| padding: 24px 28px; | |
| font-family: 'Nunito', sans-serif; | |
| color: #ff4444; | |
| text-align: center; | |
| font-size: 1.05rem; | |
| ">⚠️ {msg}</div>""" | |
| def _smile_bar_html(score: int) -> str: | |
| color = ( | |
| "#ff6b6b" if score < 30 else | |
| "#ffd166" if score < 55 else | |
| "#06d6a0" if score < 75 else | |
| "#2563EB" | |
| ) | |
| emoji = ( | |
| "😐" if score < 20 else | |
| "🙂" if score < 40 else | |
| "😊" if score < 60 else | |
| "😍" if score < 80 else | |
| "🌟" | |
| ) | |
| return f""" | |
| <div style="margin: 12px 0;"> | |
| <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;"> | |
| <span style="font-weight:700;font-size:1.1rem;color:#333;">Skor Kemanisan Senyuman</span> | |
| <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> | |
| </div> | |
| <div style="background:#e8e8e8;border-radius:99px;height:14px;overflow:hidden;"> | |
| <div style="width:{score}%;height:100%;background:linear-gradient(90deg,{color}88,{color});border-radius:99px;transition:width 1s ease;"></div> | |
| </div> | |
| </div>""" | |
| def _prob_rows_html(adj_weights: list[float], chosen_value: int) -> str: | |
| rows = "" | |
| for r, w in zip(REWARD_TABLE, adj_weights): | |
| is_winner = (r["value"] == chosen_value) | |
| bg = "background:linear-gradient(90deg,#2563EB,#7C3AED);color:#fff;font-weight:800;" if is_winner else "" | |
| badge = " ← Kamu! 🎯" if is_winner else "" | |
| rows += f""" | |
| <tr style="{bg}border-radius:8px;"> | |
| <td style="padding:10px 14px;border-radius:8px 0 0 8px;">{r['emoji']} {r['label']}</td> | |
| <td style="padding:10px 14px;text-align:center;">{w*100:.2f}%</td> | |
| <td style="padding:10px 14px;border-radius:0 8px 8px 0;">{badge}</td> | |
| </tr>""" | |
| return rows | |
| def _build_result_html( | |
| smile_score: int, | |
| description: str, | |
| reward: int, | |
| adj_weights: list[float], | |
| final_price: int, | |
| is_free: bool, | |
| ) -> str: | |
| if is_free: | |
| price_display = "G R A T I S" | |
| final_style = "color:#2563EB;font-size:1.6rem;font-weight:900;" | |
| banner = f"""<div style="background:linear-gradient(135deg,#2563EB,#7C3AED);border-radius:16px;padding:20px;text-align:center;color:#FFFFFF;margin-bottom:20px;animation:pulse 1s infinite;"> | |
| <div style="font-size:1.4rem;font-weight:900;letter-spacing:2px;">SELAMAT! KAMU DAPAT GRATIS!</div> | |
| <div style="font-size:0.9rem;opacity:0.9;margin-top:4px;">Senyumanmu luar biasa! Sangat jarang terjadi (0.01%)</div> | |
| </div>""" | |
| else: | |
| price_display = fmt_rp(final_price) | |
| final_style = "color:#111827;font-size:1.4rem;font-weight:800;" | |
| banner = "" | |
| if reward >= 3000: | |
| banner = """<div style="background:linear-gradient(135deg,#2563EB,#7C3AED);border-radius:16px;padding:14px;text-align:center;color:#FFFFFF;margin-bottom:16px;"> | |
| <span style="font-size:1.2rem;font-weight:700;">Diskon Besar! Senyumanmu sangat manis!</span> | |
| </div>""" | |
| discount_label = "GRATIS" if is_free else fmt_rp(reward) | |
| return f""" | |
| <div style="font-family:'Nunito',sans-serif;max-width:100%;"> | |
| {banner} | |
| <div style="background:#fff;border-radius:20px;padding:24px;box-shadow:0 4px 24px #0001;margin-bottom:16px;"> | |
| <h3 style="margin:0 0 12px;color:#111827;font-size:1.15rem;">Analisis Senyuman</h3> | |
| {_smile_bar_html(smile_score)} | |
| <p style="background:#f0f4ff;border-left:4px solid #2563EB;padding:10px 14px;border-radius:0 10px 10px 0;margin:12px 0 0;font-style:italic;color:#6B7280;font-size:0.95rem;">"{description}"</p> | |
| </div> | |
| <div style="background:linear-gradient(135deg,#2563EB,#7C3AED);border-radius:20px;padding:24px;color:#FFFFFF;box-shadow:0 4px 24px #2563EB33;"> | |
| <h3 style="margin:0 0 16px;font-size:1.15rem;color:#FFFFFF;">Ringkasan Pembelian</h3> | |
| <div style="display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid #ffffff33;"> | |
| <span style="color:#F9FAFB;opacity:0.85;">Harga Normal</span> | |
| <span style="font-weight:600;color:#FFFFFF;">{fmt_rp(BASE_PRICE)}</span> | |
| </div> | |
| <div style="display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid #ffffff33;"> | |
| <span style="color:#F9FAFB;opacity:0.85;">Diskon Senyuman</span> | |
| <span style="font-weight:700;color:#BFDBFE;">- {discount_label}</span> | |
| </div> | |
| <div style="display:flex;justify-content:space-between;padding:14px 0 0;"> | |
| <span style="font-size:1.1rem;font-weight:700;color:#FFFFFF;">Harga Final</span> | |
| <span style="{final_style}">{price_display}</span> | |
| </div> | |
| </div> | |
| </div> | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800;900&display=swap'); | |
| @keyframes pulse {{0%,100%{{opacity:1}}50%{{opacity:.7}}}} | |
| </style>""" | |
| # CUSTOM CSS | |
| CUSTOM_CSS = """ | |
| @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800;900&family=Pacifico&display=swap'); | |
| :root { | |
| --color-primary: #2563EB; | |
| --color-secondary: #7C3AED; | |
| --color-accent: #4cc9f0; | |
| --color-bg: #f8faff; | |
| --color-card: #ffffff; | |
| --radius: 20px; | |
| } | |
| body, .gradio-container { | |
| font-family: 'Nunito', sans-serif !important; | |
| background: var(--color-bg) !important; | |
| } | |
| /* Center column layout */ | |
| .center-col { | |
| max-width: 640px; | |
| margin: 0 auto; | |
| width: 100%; | |
| } | |
| /* Header */ | |
| .smile-header { | |
| text-align: center; | |
| padding: 32px 16px 20px; | |
| } | |
| .smile-header .big-title { | |
| font-family: 'Pacifico', cursive; | |
| font-size: clamp(2rem, 5vw, 3.2rem); | |
| background: linear-gradient(135deg, #2563EB, #7C3AED); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| background-clip: text; | |
| margin: 0; | |
| line-height: 1.1; | |
| } | |
| .smile-header .subtitle { | |
| color: #6B7280; | |
| font-size: 1rem; | |
| margin: 6px 0 0; | |
| } | |
| /* Tabs */ | |
| .tab-nav button { | |
| font-family: 'Nunito', sans-serif !important; | |
| font-weight: 700 !important; | |
| font-size: 1rem !important; | |
| border-radius: 99px !important; | |
| padding: 8px 24px !important; | |
| transition: all .2s !important; | |
| } | |
| .tab-nav button.selected { | |
| background: linear-gradient(135deg, #2563EB, #7C3AED) !important; | |
| color: #fff !important; | |
| box-shadow: 0 4px 16px #2563EB44 !important; | |
| } | |
| /* Main analyze button */ | |
| .btn-analyze { | |
| background: linear-gradient(135deg, #2563EB, #7C3AED) !important; | |
| color: #fff !important; | |
| font-family: 'Nunito', sans-serif !important; | |
| font-weight: 800 !important; | |
| font-size: 1.15rem !important; | |
| border-radius: 99px !important; | |
| padding: 14px 0 !important; | |
| border: none !important; | |
| cursor: pointer !important; | |
| transition: transform .15s, box-shadow .15s !important; | |
| box-shadow: 0 6px 24px #2563EB55 !important; | |
| width: 100%; | |
| } | |
| .btn-analyze:hover { | |
| transform: translateY(-2px) !important; | |
| box-shadow: 0 10px 32px #2563EB77 !important; | |
| } | |
| .btn-analyze:active { transform: translateY(0) !important; } | |
| /* Reset button */ | |
| .btn-reset, | |
| .btn-reset button, | |
| button.btn-reset { | |
| background: #f0f0f0 !important; | |
| color: #374151 !important; | |
| font-family: 'Nunito', sans-serif !important; | |
| font-weight: 700 !important; | |
| border-radius: 99px !important; | |
| border: 1px solid #e5e7eb !important; | |
| cursor: pointer !important; | |
| transition: background .15s !important; | |
| width: 100%; | |
| } | |
| .btn-reset span, | |
| .btn-reset span *, | |
| button.btn-reset span { color: #374151 !important; } | |
| .btn-reset:hover, | |
| button.btn-reset:hover { background: #e5e7eb !important; } | |
| /* Force visible text on analyze/coba lagi buttons */ | |
| .btn-analyze span, | |
| .btn-analyze span * { color: #fff !important; } | |
| /* Gradio Image component — clipboard & upload button text */ | |
| .image-upload-area button, | |
| .image-upload-area button span, | |
| .image-upload-area .upload-container button span, | |
| .image-upload-area [data-testid="upload-button"] span { | |
| color: #2563EB !important; | |
| font-family: 'Nunito', sans-serif !important; | |
| font-weight: 700 !important; | |
| } | |
| /* The small icon-only pill button inside image area */ | |
| .image-upload-area .icon-button, | |
| .image-upload-area .icon-button span { | |
| color: #fff !important; | |
| background: linear-gradient(135deg, #2563EB, #7C3AED) !important; | |
| } | |
| /* Cards */ | |
| .info-card { | |
| background: linear-gradient(135deg, #2563EB, #7C3AED); | |
| border-radius: var(--radius); | |
| padding: 20px 24px; | |
| color: #F9FAFB; | |
| margin-bottom: 16px; | |
| } | |
| .info-card h4 { margin: 0 0 10px; font-size: 1rem; opacity: .85; font-weight: 700; } | |
| .info-card .price-big { font-size: 2rem; font-weight: 900; } | |
| /* Reward pills */ | |
| .reward-pill { | |
| display: inline-block; | |
| padding: 4px 14px; | |
| border-radius: 99px; | |
| font-weight: 700; | |
| font-size: 0.88rem; | |
| background: #2563EB; | |
| color: #fff; | |
| margin: 3px; | |
| } | |
| /* Score progress bar */ | |
| #smile-score-slider .wrap { pointer-events: none; } | |
| #smile-score-slider input[type=range] { | |
| accent-color: #2563EB; | |
| } | |
| /* Result panel */ | |
| #result-panel .prose { font-family: 'Nunito', sans-serif !important; } | |
| /* Image areas */ | |
| .image-upload-area { | |
| border: 2.5px dashed #2563EB77 !important; | |
| border-radius: var(--radius) !important; | |
| background: #f5f8ff !important; | |
| min-height: 260px; | |
| } | |
| /* Mirror webcam: flip video feed horizontally */ | |
| #webcam-mirror video, | |
| #webcam-mirror canvas { | |
| transform: scaleX(-1) !important; | |
| } | |
| """ | |
| # GRADIO UI | |
| def reset_ui(): | |
| return None, None, "", 0, gr.update(visible=False) | |
| _theme = gr.themes.Base( | |
| font=gr.themes.GoogleFont("Nunito"), | |
| primary_hue=gr.themes.colors.indigo, | |
| secondary_hue=gr.themes.colors.violet, | |
| neutral_hue=gr.themes.colors.gray, | |
| radius_size=gr.themes.sizes.radius_lg, | |
| ).set( | |
| button_primary_background_fill="linear-gradient(135deg, #2563EB, #7C3AED)", | |
| button_primary_text_color="#ffffff", | |
| button_secondary_background_fill="#f0f0f0", | |
| button_secondary_text_color="#374151", | |
| button_secondary_border_color="#e5e7eb", | |
| ) | |
| with gr.Blocks(title="Smile Score — Diskon dengan Senyuman") as demo: | |
| gr.HTML(""" | |
| <div class="smile-header"> | |
| <h1 class="big-title">Smile Score</h1> | |
| <p class="subtitle">Senyum lebih manis, diskon lebih besar! Harga normal <strong>Rp 6.000</strong></p> | |
| </div> | |
| """) | |
| with gr.Column(elem_classes="center-col"): | |
| with gr.Tabs(elem_classes="tab-nav") as input_tabs: | |
| with gr.TabItem("Selfie (Webcam)", id="tab_webcam"): | |
| webcam_input = gr.Image( | |
| sources="webcam", | |
| type="numpy", | |
| label="Ambil Selfie", | |
| elem_id="webcam-mirror", | |
| elem_classes="image-upload-area", | |
| height=300, | |
| ) | |
| gr.HTML("<p style='text-align:center;color:#aaa;font-size:0.85rem;margin-top:6px;'>Pastikan wajahmu terlihat jelas & tersenyum manis!</p>") | |
| with gr.TabItem("Upload Foto", id="tab_upload"): | |
| upload_input = gr.Image( | |
| sources=["upload"], | |
| type="numpy", | |
| label="Upload Foto", | |
| elem_classes="image-upload-area", | |
| height=300, | |
| placeholder="# Upload Foto\nDrag & drop atau klik untuk pilih foto\nFormat JPG / PNG / WEBP", | |
| ) | |
| gr.HTML("<p style='text-align:center;color:#aaa;font-size:0.85rem;margin-top:6px;'>Format JPG / PNG / WEBP. Max 10MB.</p>") | |
| gr.HTML("<div style='height:12px;'></div>") | |
| analyze_btn = gr.Button( | |
| "Mulai Analisis", | |
| elem_classes="btn-analyze", | |
| variant="primary", | |
| ) | |
| reset_btn = gr.Button( | |
| "Reset", | |
| elem_classes="btn-reset", | |
| variant="secondary", | |
| ) | |
| gr.HTML(""" | |
| <div class="info-card" style="margin-top:16px;"> | |
| <h4>Harga Normal Produk</h4> | |
| <div class="price-big">Rp 6.000</div> | |
| <div style="font-size:0.88rem;opacity:.8;margin-top:4px;"> | |
| Senyum tulus = peluang diskon makin besar! | |
| </div> | |
| </div> | |
| """) | |
| with gr.Accordion("Tips Senyuman Manis", open=False): | |
| gr.HTML(""" | |
| <ul style="font-size:0.92rem;color:#555;line-height:1.8;margin:0;padding-left:18px;"> | |
| <li>Biarkan senyumanmu <strong>alami & tulus</strong></li> | |
| <li>Ikutkan <strong>matamu tersenyum</strong> (efek Duchenne)</li> | |
| <li>Tampilkan <strong>gigi</strong> untuk nilai lebih</li> | |
| <li>Pastikan <strong>pencahayaan cukup</strong> & wajah jelas</li> | |
| <li>Bayangkan hal yang membuatmu <strong>bahagia!</strong></li> | |
| <li>Lepas <strong>kacamata, topi, masker</strong>, atau aksesori lain yang menutupi wajah untuk hasil lebih akurat</li> | |
| </ul> | |
| """) | |
| gr.HTML("<h3 style='margin:24px 0 12px;font-family:Nunito,sans-serif;color:#111827;'>Hasil Analisis</h3>") | |
| smile_score_slider = gr.Slider( | |
| minimum=0, maximum=100, value=0, | |
| label="Skor Senyuman", | |
| interactive=False, | |
| elem_id="smile-score-slider", | |
| ) | |
| result_html = gr.HTML( | |
| value="""<div style="background:#f9f9f9;border-radius:20px;padding:40px 24px;text-align:center;color:#bbb;font-family:Nunito,sans-serif;"> | |
| <div style='margin-top:8px;font-size:1rem;'>Ambil selfie atau upload foto,<br>lalu klik <strong>Mulai Analisis</strong></div> | |
| </div>""", | |
| elem_id="result-panel", | |
| ) | |
| again_btn = gr.Button( | |
| "Coba Lagi!", | |
| elem_classes="btn-analyze", | |
| visible=False, | |
| variant="primary", | |
| ) | |
| # Footer | |
| gr.HTML(""" | |
| <div style="text-align:center;padding:28px 16px 12px;color:#ccc;font-family:Nunito,sans-serif;font-size:0.85rem;"> | |
| Powered by <strong style="color:#2563EB;">Qwen2.5-VL-3B-Instruct</strong> · | |
| Built with love on <strong>Hugging Face Spaces</strong> | |
| </div> | |
| """) | |
| active_image = gr.State(None) | |
| def merge_inputs(webcam_img, upload_img): | |
| img = webcam_img if webcam_img is not None else upload_img | |
| return img | |
| def on_analyze(webcam_img, upload_img): | |
| img = merge_inputs(webcam_img, upload_img) | |
| html, score, again_vis = run_analysis(img) | |
| return html, score, again_vis | |
| analyze_btn.click( | |
| fn=on_analyze, | |
| inputs=[webcam_input, upload_input], | |
| outputs=[result_html, smile_score_slider, again_btn], | |
| ) | |
| again_btn.click( | |
| fn=on_analyze, | |
| inputs=[webcam_input, upload_input], | |
| outputs=[result_html, smile_score_slider, again_btn], | |
| ) | |
| reset_btn.click( | |
| fn=lambda: ( | |
| None, None, | |
| """<div style="background:#f9f9f9;border-radius:20px;padding:40px 24px;text-align:center;color:#bbb;font-family:Nunito,sans-serif;"> | |
| <div style='margin-top:8px;font-size:1rem;'>Ambil selfie atau upload foto,<br>lalu klik <strong>Mulai Analisis</strong></div> | |
| </div>""", | |
| 0, | |
| gr.update(visible=False), | |
| ), | |
| inputs=[], | |
| outputs=[webcam_input, upload_input, result_html, smile_score_slider, again_btn], | |
| ) | |
| demo.launch( | |
| server_name="0.0.0.0", | |
| server_port=7860, | |
| share=False, | |
| css=CUSTOM_CSS, | |
| theme=_theme, | |
| ) |