""" Pour Judgement — drop a photo of your latte art, face the judge. Gradio app for the Build Small Hackathon (Thousand Token Wood track). Local-only inference: OpenCV metrics + a small VLM via llama.cpp. """ from __future__ import annotations import os # ------------------------ SSL fix: clear cert env vars pointing to missing files # (must run before importing gradio/httpx — they read these at import time) for _var in ("SSL_CERT_FILE", "SSL_CERT_DIR", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE"): _p = os.environ.get(_var, "") if _p and not os.path.exists(_p): print(f"[app] Clearing {_var} -> file not found: {_p}") os.environ.pop(_var, None) import gradio as gr from judge import JUDGE_NAME, JUDGE_TITLE, judge from scoring import score_image # ----------------------------------------------------------------- UI pieces CSS = """ @import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400..900;1,9..144,400..700&family=Inter:wght@400;600&display=swap'); :root, .gradio-container { --espresso: #241712; --roast: #3a2a20; --crema: #e9c9a0; --milk: #faf4eb; --ink: #2a1e18; --stamp: #8c3b2e; } .gradio-container { background: var(--espresso) !important; font-family: 'Inter', sans-serif !important; max-width: 860px !important; margin: 0 auto !important; } #title h1 { font-family: 'Fraunces', serif !important; font-weight: 900; font-size: 2.6rem; letter-spacing: -0.02em; color: var(--crema); text-align: center; margin-bottom: 0; } #subtitle p { color: #b59d85; text-align: center; font-size: 0.95rem; margin-top: 0.25rem; } #drop .image-container, #drop { border: 2px dashed #6b5443 !important; border-radius: 14px !important; background: var(--roast) !important; } #go { background: var(--crema) !important; color: var(--ink) !important; font-family: 'Fraunces', serif !important; font-weight: 700 !important; font-size: 1.15rem !important; border: none !important; border-radius: 10px !important; } #go:hover { background: #f3dcbb !important; } /* The scorecard — signature element. Colors are hard-pinned with !important because Gradio's dark theme injects white text into HTML-component children, which made the tip unreadable. */ .scorecard { background: var(--milk) !important; color: #2a1e18 !important; border-radius: 6px; padding: 28px 30px; position: relative; box-shadow: 0 12px 40px rgba(0,0,0,0.45); font-family: 'Inter', sans-serif; } .scorecard * { color: #2a1e18 !important; } .scorecard .header { font-family: 'Fraunces', serif; font-size: 0.8rem; letter-spacing: 0.18em; text-transform: uppercase; color: #7a6552 !important; border-bottom: 1px solid #d9c8b4; padding-bottom: 10px; margin-bottom: 16px; } .scorecard .pattern { font-family: 'Fraunces', serif; font-style: italic; font-weight: 600; font-size: 1.5rem; line-height: 1.25; margin-bottom: 14px; } .scorecard .verdict { font-size: 1rem; line-height: 1.55; margin-bottom: 18px; } .scorecard .tip { background: #f1e4d2 !important; border-left: 4px solid #c89a64; padding: 12px 14px; border-radius: 0 8px 8px 0; font-size: 0.95rem; } .scorecard .tip, .scorecard .tip b { color: #4a3526 !important; } .scorecard .tip b { font-family: 'Fraunces', serif; } .stamp { position: absolute; top: 18px; right: 22px; transform: rotate(8deg); border: 3px solid var(--stamp); color: var(--stamp) !important; border-radius: 8px; font-family: 'Fraunces', serif; font-weight: 900; font-size: 1.9rem; padding: 6px 14px; opacity: 0.85; } .bars { margin-top: 18px; } .bar-row { display: flex; align-items: center; gap: 10px; margin: 7px 0; } .bar-label { width: 112px; font-size: 0.78rem; letter-spacing: 0.06em; text-transform: uppercase; color: #7a6552 !important; } .bar-track { flex: 1; height: 10px; background: #e3d3bd !important; border-radius: 5px; overflow: hidden; } .bar-fill { height: 100%; background: linear-gradient(90deg, #c89a64, #8c5a33) !important; border-radius: 5px; } .bar-num { width: 38px; text-align: right; font-size: 0.82rem; font-weight: 600; color: #2a1e18 !important; } .judging { color: var(--crema); font-family: 'Fraunces', serif; font-style: italic; text-align: center; font-size: 1.05rem; } footer { display: none !important; } """ JUDGING_LINES = "The judge lifts the cup to the light… swirls… inhales… deliberates…" BAR_LABELS = {"texture": "milk texture", "flow": "flow & structure"} def render_card(result: dict, verdict: dict) -> str: s = result["subscores"] bars = "".join( f"""
{BAR_LABELS.get(name, name)}
{val:.0f}
""" for name, val in s.items() ) return f"""
{result['total']:.0f}
Official Scorecard — {JUDGE_NAME}, {JUDGE_TITLE}
“{verdict['pattern']}”
{verdict['verdict']}
The Tip: {verdict['tip']}
{bars}
""" def face_the_judge(image_path, progress=gr.Progress()): if image_path is None: return "
The judge cannot score an empty saucer. Add a photo.
" progress(0.1, desc="Measuring the pour…") result = score_image(image_path) progress(0.35, desc=JUDGING_LINES) verdict = judge(image_path, result) progress(1.0, desc="Verdict reached.") return render_card(result, verdict) with gr.Blocks(title="Pour Judgement") as demo: gr.Markdown("# Pour Judgement", elem_id="title") gr.Markdown( f"Drop a photo of your latte art. *{JUDGE_NAME}* will take it from there.", elem_id="subtitle", ) image = gr.Image(type="filepath", label=" ", elem_id="drop", height=340) go = gr.Button("Face the Judge", elem_id="go", size="lg") card = gr.HTML() go.click(face_the_judge, inputs=image, outputs=card) image.upload(lambda: "", outputs=card) # clear old verdict on new photo if __name__ == "__main__": demo.launch(css=CSS)