Download game/ui.py from build-small-hackathon/Forager-Field-Notes: direct link, hf CLI and curl.
- Browser
- Download file 13.9 kB
-
https://huggingface.co/spaces/build-small-hackathon/Forager-Field-Notes/resolve/refs%2Fpr%2F2/game/ui.py
- Command line
-
hf download hf://spaces/build-small-hackathon/Forager-Field-Notes@refs/pr/2/game/ui.py
-
curl -L -o ui.py https://huggingface.co/spaces/build-small-hackathon/Forager-Field-Notes/resolve/refs%2Fpr%2F2/game/ui.py
13.9 kB
| """ | |
| 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 # ranked only after 5 rounds, so a 1-round 100% can't top the board | |
| 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 "β" | |
| # color set inline with !important on the spans: Gradio's theme paints spans in | |
| # the display panel a light color that overrides inherited/class color (the big | |
| # truth div is fine, but these spans aren't) β inline !important wins the cascade. | |
| 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") | |
| # ββ handlers (closures capture `pipe`) βββββββββββββββββββββββββββββββββββ | |
| 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 # ignore double-taps / no card yet | |
| 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]) | |
| # ββ Stump the Machine: upload your own find -> the data flywheel ββββββββββββββ | |
| _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() # contributor board unchanged | |
| 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]) | |