Smile_Score / app.py
RangGaraga's picture
Update app.py
b44ee25 verified
Raw
History Blame Contribute Delete
23.1 kB
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,
)