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": , "description": "", "has_face": }""" # 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"""
โš ๏ธ {msg}
""" 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"""
Skor Kemanisan Senyuman {emoji} {score}/100
""" 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""" {r['emoji']} {r['label']} {w*100:.2f}% {badge} """ 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"""
SELAMAT! KAMU DAPAT GRATIS!
Senyumanmu luar biasa! Sangat jarang terjadi (0.01%)
""" else: price_display = fmt_rp(final_price) final_style = "color:#111827;font-size:1.4rem;font-weight:800;" banner = "" if reward >= 3000: banner = """
Diskon Besar! Senyumanmu sangat manis!
""" discount_label = "GRATIS" if is_free else fmt_rp(reward) return f"""
{banner}

Analisis Senyuman

{_smile_bar_html(smile_score)}

"{description}"

Ringkasan Pembelian

Harga Normal {fmt_rp(BASE_PRICE)}
Diskon Senyuman - {discount_label}
Harga Final {price_display}
""" # 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("""

Smile Score

Senyum lebih manis, diskon lebih besar! Harga normal Rp 6.000

""") 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("

Pastikan wajahmu terlihat jelas & tersenyum manis!

") 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("

Format JPG / PNG / WEBP. Max 10MB.

") gr.HTML("
") 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("""

Harga Normal Produk

Rp 6.000
Senyum tulus = peluang diskon makin besar!
""") with gr.Accordion("Tips Senyuman Manis", open=False): gr.HTML("""
  • Biarkan senyumanmu alami & tulus
  • Ikutkan matamu tersenyum (efek Duchenne)
  • Tampilkan gigi untuk nilai lebih
  • Pastikan pencahayaan cukup & wajah jelas
  • Bayangkan hal yang membuatmu bahagia!
  • Lepas kacamata, topi, masker, atau aksesori lain yang menutupi wajah untuk hasil lebih akurat
""") gr.HTML("

Hasil Analisis

") 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="""
Ambil selfie atau upload foto,
lalu klik Mulai Analisis
""", elem_id="result-panel", ) again_btn = gr.Button( "Coba Lagi!", elem_classes="btn-analyze", visible=False, variant="primary", ) # Footer gr.HTML("""
Powered by Qwen2.5-VL-3B-Instruct ยท Built with love on Hugging Face Spaces
""") 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, """
Ambil selfie atau upload foto,
lalu klik Mulai Analisis
""", 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, )