| """ |
| game/ui.py β "Beat the Machine" duel tab. |
| |
| A card from the curated deck is dealt; you call its safety tier (SAFE / CAUTION / |
| DEADLY) and the same on-device pipeline calls it at the same instant. Score |
| accrues per session; post it to the leaderboard. The machine's edge is that it |
| refuses when unsure β so the deadly cards punish overconfidence in both players. |
| Stay sharper than the machine you built. |
| """ |
|
|
| from functools import partial |
|
|
| import gradio as gr |
| import pandas as pd |
| from PIL import Image |
|
|
| from pipeline.convergence import build_result |
|
|
| from . import datastore |
| from . import deck as deck_mod |
|
|
| TIER_COLOR = {"SAFE": "#2f6b2b", "CAUTION": "#87671c", "DEADLY": "#8c1d14", "UNKNOWN": "#57544c"} |
| _LB_COLS = ["Player", "You %", "Machine %", "Rounds"] |
| MIN_RANK_ROUNDS = 5 |
|
|
|
|
| def _machine_call(pipe, path: str): |
| res = build_result(pipe.identify(Image.open(path))) |
| tier = "UNKNOWN" if res.abstained else res.safety |
| return tier, res |
|
|
|
|
| def _scoreboard(score: dict) -> str: |
| return ( |
| f"<div class='gm-score'>" |
| f"<span class='gm-you'>YOU <b>{score['you']}</b></span>" |
| f"<span class='gm-vs'>/ {score['total']} Β· vs Β·</span>" |
| f"<span class='gm-mach'>MACHINE <b>{score['machine']}</b></span>" |
| f"</div>" |
| ) |
|
|
|
|
| def _pretty(species: str) -> str: |
| return species.replace("_toxic", "").replace("_deadly", "").replace("_", " ").title() |
|
|
|
|
| def _chip(label: str, tier: str, right: bool) -> str: |
| c = TIER_COLOR.get(tier, "#57544c") |
| mark = "β" if right else "β" |
| |
| |
| |
| return ( |
| f"<div class='gm-chip' style='border-color:{c}'>" |
| f"<span class='gm-chip-h' style='color:{c} !important'>{label}</span>" |
| f"<span class='gm-chip-t' style='color:{c} !important'>{tier} {mark}</span></div>" |
| ) |
|
|
|
|
| def _reveal_html(card: dict, you_tier: str, you_right: bool, mtier: str, mach_right: bool) -> str: |
| tc = TIER_COLOR[card["tier"]] |
| if you_right and not mach_right: |
| flav = "π You beat the machine on this one." |
| elif mach_right and not you_right: |
| flav = "The machine got you. Study the look-alike." |
| elif you_right and mach_right: |
| flav = "Dead heat β both correct." |
| else: |
| flav = "Neither nailed it. The woods don't grade on a curve." |
| abst = "" |
| if mtier == "UNKNOWN": |
| abst = ("<div class='gm-abst'>The machine refused to commit β that's its whole " |
| "point. A refusal beats a confident wrong call.</div>") |
| return ( |
| f"<div class='gm-reveal'>" |
| f"<div class='gm-truth-h'>TRUTH</div>" |
| f"<div class='gm-truth' style='color:{tc}'>{card['tier']}</div>" |
| f"<div class='gm-species'>{_pretty(card['species'])} Β· <i>{card['scientific']}</i></div>" |
| f"<div class='gm-chips'>{_chip('YOUR CALL', you_tier, you_right)}" |
| f"{_chip('MACHINE', mtier, mach_right)}</div>" |
| f"<div class='gm-flav'>{flav}</div>{abst}</div>" |
| ) |
|
|
|
|
| def _lb_df() -> pd.DataFrame: |
| data = [] |
| for r in datastore.load_leaderboard(): |
| t = int(r.get("skill_total", 0) or 0) |
| if t < MIN_RANK_ROUNDS: |
| continue |
| data.append([ |
| r.get("contributor", "?"), |
| round(100 * r.get("skill_correct", 0) / t), |
| round(100 * r.get("machine_correct", 0) / t), |
| t, |
| ]) |
| data.sort(key=lambda x: (-x[1], -x[3])) |
| return pd.DataFrame(data, columns=_LB_COLS) |
|
|
|
|
| def build_game_tab(pipe) -> None: |
| """Build the duel tab inside the current gr.Blocks context.""" |
| gr.HTML( |
| "<div class='gm-intro'>" |
| "<div class='gm-intro-h'>CAN YOU BEAT THE MACHINE?</div>" |
| "<div class='gm-intro-b'>We built the machine. Now try to out-forage it. " |
| "A card is dealt β call its tier before it does. Remember: the machine's " |
| "trick is knowing when <i>not</i> to guess. Every round you play sharpens you " |
| "and sharpens the open dataset behind it.</div></div>" |
| ) |
|
|
| score_state = gr.State({"you": 0, "machine": 0, "total": 0}) |
| card_state = gr.State(None) |
|
|
| with gr.Row(): |
| with gr.Column(scale=1): |
| card_img = gr.Image(type="filepath", show_label=False, interactive=False, |
| elem_classes="eink-input", height=300) |
| deal_btn = gr.Button("βΈ DEAL A CARD", variant="primary", elem_classes="eink-scan") |
| with gr.Row(): |
| safe_b = gr.Button("SAFE", elem_classes="gm-btn gm-safe") |
| caut_b = gr.Button("CAUTION", elem_classes="gm-btn gm-caut") |
| dead_b = gr.Button("DEADLY", elem_classes="gm-btn gm-dead") |
| with gr.Column(scale=1, elem_classes="eink-screen"): |
| reveal = gr.HTML("<div class='gm-idle'>Deal a card to start the duel.</div>") |
|
|
| score_html = gr.HTML(_scoreboard({"you": 0, "machine": 0, "total": 0})) |
|
|
| gr.HTML("<div class='gm-divider'>β LEADERBOARD Β· ranked at 5+ rounds</div>") |
| post_btn = gr.Button("POST MY SCORE", elem_classes="eink-scan") |
| lb_status = gr.HTML("") |
| lb = gr.Dataframe(value=_lb_df(), headers=_LB_COLS, interactive=False, |
| elem_classes="gm-lb", wrap=True) |
| refresh_btn = gr.Button("β³ refresh", elem_classes="gm-refresh") |
|
|
| |
| def _deal(score, card): |
| prev = card["file"] if card else None |
| c = deck_mod.random_card(exclude_file=prev) |
| new = {**c, "answered": False} |
| return c["path"], new, "<div class='gm-idle'>Your call?</div>", _scoreboard(score) |
|
|
| def _guess(tier, card, score): |
| if not card or card.get("answered"): |
| return gr.update(), card, score |
| mtier, _ = _machine_call(pipe, card["path"]) |
| you_right = tier == card["tier"] |
| mach_right = mtier == card["tier"] |
| score = { |
| "you": score["you"] + int(you_right), |
| "machine": score["machine"] + int(mach_right), |
| "total": score["total"] + 1, |
| } |
| card = {**card, "answered": True} |
| return _reveal_html(card, tier, you_right, mtier, mach_right), card, score |
|
|
| def _post(score, profile: gr.OAuthProfile | None = None): |
| if profile is None: |
| return "<div class='gm-warn'>Log in (top of the page) to post your score.</div>", gr.update() |
| if score["total"] == 0: |
| return "<div class='gm-warn'>Play at least one round first.</div>", gr.update() |
| datastore.post_score(profile.username, score["you"], score["total"], score["machine"]) |
| note = "" if datastore.persistence_enabled() else \ |
| " <span class='gm-note'>(session-only until the dataset token is set)</span>" |
| return f"<div class='gm-ok'>Posted as {profile.username}.{note}</div>", _lb_df() |
|
|
| deal_btn.click(_deal, [score_state, card_state], [card_img, card_state, reveal, score_html]) |
| for b, t in [(safe_b, "SAFE"), (caut_b, "CAUTION"), (dead_b, "DEADLY")]: |
| b.click(partial(_guess, t), [card_state, score_state], [reveal, card_state, score_state]) \ |
| .then(_scoreboard, [score_state], [score_html]) |
| post_btn.click(_post, [score_state], [lb_status, lb]) |
| refresh_btn.click(lambda: _lb_df(), None, [lb]) |
|
|
|
|
| |
| _CONTRIB_COLS = ["Contributor", "Sightings"] |
|
|
|
|
| def _contrib_df() -> pd.DataFrame: |
| rows = datastore.load_contributors() |
| return pd.DataFrame([[r["contributor"], r["count"]] for r in rows], columns=_CONTRIB_COLS) |
|
|
|
|
| def _stump_unrouted_html(status: str) -> str: |
| body = { |
| "stored": "<div class='gm-ok'>Saved for review. If it's a real find, you just handed us a " |
| "training example the model is missing.</div>", |
| "duplicate": "<div class='gm-warn'>Already flagged for review β this one's logged.</div>", |
| "disabled": "<div class='gm-warn'>Couldn't save for review (review-queue token scope not set " |
| "on the Space).</div>", |
| }.get(status, "") |
| return ( |
| "<div class='gm-reveal'>" |
| "<div class='gm-truth-h'>YOU FOUND A GAP</div>" |
| "<div class='gm-truth' style='color:#57544c'>OFF THE MAP</div>" |
| "<div class='gm-species'>The machine couldn't place this in berry, mushroom, or plant β " |
| "the ultimate stump.</div>" |
| f"{body}" |
| "<div class='gm-abst'>Genuinely off-topic shots (not a wild plant, mushroom, or berry) get " |
| "filtered out in review.</div>" |
| "</div>" |
| ) |
|
|
|
|
| def _stump_result_html(label: str, res, status: str) -> str: |
| mtier = "UNKNOWN" if res.abstained else res.safety |
| c = TIER_COLOR.get(mtier, "#57544c") |
| mcall = "refused to commit" if res.abstained else f"{_pretty(res.species)} Β· {mtier}" |
| head = "π You stumped the machine!" if res.abstained else "The machine made its call." |
| store_line = { |
| "stored": "<div class='gm-ok'>β Added to the open dataset β thank you. This is exactly " |
| "the data that makes the next model better.</div>", |
| "duplicate": "<div class='gm-warn'>Already in the dataset β this photo (or a near-match) " |
| "was logged before, so it wasn't added again.</div>", |
| "disabled": "<div class='gm-warn'>Couldn't persist (dataset token not set on the Space).</div>", |
| }.get(status, "") |
| return ( |
| f"<div class='gm-reveal'>" |
| f"<div class='gm-truth-h'>YOUR FIND</div>" |
| f"<div class='gm-flav'>{head}</div>" |
| f"<div class='gm-species'>you said: <b>{label}</b></div>" |
| f"<div class='gm-chips'><div class='gm-chip' style='border-color:{c}'>" |
| f"<span class='gm-chip-h' style='color:{c} !important'>MACHINE</span>" |
| f"<span class='gm-chip-t' style='color:{c} !important'>{mcall}</span></div></div>" |
| f"{store_line}" |
| f"<div class='gm-abst'>An ID here is never permission to eat β verify with an expert.</div>" |
| f"</div>" |
| ) |
|
|
|
|
| def build_stump_tab(pipe) -> None: |
| """Upload a real find: the router gates it, the model calls it, and (consented) it |
| joins the public dataset. The flywheel β every submission trains the next model.""" |
| gr.HTML( |
| "<div class='gm-intro'>" |
| "<div class='gm-intro-h'>STUMP THE MACHINE</div>" |
| "<div class='gm-intro-b'>Upload your own find. The machine calls it on the spot β and " |
| "when it can't, you've stumped it. Every accepted photo joins the open " |
| "<b>CC-BY-4.0</b> dataset that trains the next model. You're not just playing; you're " |
| "building the thing.</div></div>" |
| ) |
|
|
| with gr.Row(): |
| with gr.Column(scale=1): |
| up = gr.Image(type="pil", sources=["upload", "webcam"], label="YOUR FIND", |
| elem_classes="eink-input", height=300) |
| guess = gr.Textbox(label="What do you think it is?", placeholder="e.g. chanterelle", |
| elem_classes="eink-input") |
| consent = gr.Checkbox( |
| value=False, |
| label="Contribute this photo to the public CC-BY-4.0 dataset (HomesteaderLabs/forager-sightings).") |
| submit = gr.Button("βΈ SUBMIT FIND", variant="primary", elem_classes="eink-scan") |
| with gr.Column(scale=1, elem_classes="eink-screen"): |
| sresult = gr.HTML("<div class='gm-idle'>Upload a find, add your guess, and submit.</div>") |
|
|
| gr.HTML("<div class='gm-divider'>β TOP CONTRIBUTORS</div>") |
| contrib = gr.Dataframe(value=_contrib_df(), headers=_CONTRIB_COLS, interactive=False, |
| elem_classes="gm-lb", wrap=True) |
| crefresh = gr.Button("β³ refresh", elem_classes="gm-refresh") |
|
|
| def _submit(image, user_label, consented, profile: gr.OAuthProfile | None = None): |
| if profile is None: |
| return "<div class='gm-warn'>Log in (top of the page) to contribute.</div>", gr.update() |
| if image is None: |
| return "<div class='gm-warn'>Upload a photo first.</div>", gr.update() |
| if not (user_label or "").strip(): |
| return "<div class='gm-warn'>Add your guess first.</div>", gr.update() |
| if not consented: |
| return "<div class='gm-warn'>Check the consent box to contribute.</div>", gr.update() |
| call = pipe.identify(image) |
| res = build_result(call) |
| in_domain = (not res.abstained) or call.get("reason", "") == "low_confidence" |
| if not in_domain: |
| router = {"domain": call.get("domain", "unknown"), |
| "domain_confidence": call.get("domain_confidence", 0.0), |
| "reason": call.get("reason", "")} |
| status = datastore.append_unrouted(image, user_label.strip(), router, profile.username) |
| return _stump_unrouted_html(status), gr.update() |
| machine = {"species": res.species, "confidence": res.confidence, |
| "abstained": res.abstained, "safety": res.safety, "domain": res.domain} |
| status = datastore.append_sighting(image, user_label.strip(), machine, profile.username) |
| board = _contrib_df() if status == "stored" else gr.update() |
| return _stump_result_html(user_label.strip(), res, status), board |
|
|
| submit.click(_submit, [up, guess, consent], [sresult, contrib]) |
| crefresh.click(lambda: _contrib_df(), None, [contrib]) |
|
|