HomesteaderLabs commited on
Commit
b5d6b93
Β·
verified Β·
1 Parent(s): 9d8d338

Deploy Forager's Field Station

Browse files
Files changed (6) hide show
  1. app.py +77 -20
  2. game/__init__.py +1 -0
  3. game/datastore.py +127 -0
  4. game/deck.json +7 -0
  5. game/deck.py +45 -0
  6. game/ui.py +172 -0
app.py CHANGED
@@ -17,6 +17,7 @@ import gradio as gr
17
 
18
  from pipeline.convergence import build_result
19
  from pipeline.infer import Pipeline
 
20
 
21
  HERE = os.path.dirname(os.path.abspath(__file__))
22
  EXAMPLES_DIR = os.path.join(HERE, "examples")
@@ -285,6 +286,57 @@ button.eink-scan:hover { background:var(--copper) !important; border-color:var(-
285
  padding:10px 14px; margin-top:10px; font-size:.74rem !important; line-height:1.55 !important;
286
  color:var(--ink2) !important; }
287
  #notice * { font-size:.74rem !important; color:var(--ink2) !important; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
  """
289
 
290
  with gr.Blocks(title="Forager's Field Station") as demo:
@@ -297,26 +349,31 @@ with gr.Blocks(title="Forager's Field Station") as demo:
297
  " <span>REFUSES BY DEFAULT</span></div>"
298
  "</div>"
299
  )
300
- gr.HTML(EXPERTS_PANEL)
301
- with gr.Row():
302
- with gr.Column(scale=1):
303
- img = gr.Image(type="pil", label="SPECIMEN", sources=["upload", "webcam"],
304
- elem_classes="eink-input", height=300)
305
- btn = gr.Button("β–Έ SCAN SPECIMEN", variant="primary", elem_classes="eink-scan")
306
- if os.path.isdir(EXAMPLES_DIR):
307
- samples = [[os.path.join(EXAMPLES_DIR, f)] for f in (
308
- "chanterelle.jpg", "lions_mane.jpg", "wild_blueberry.jpg",
309
- "yarrow.jpg", "poison_hemlock.jpg") if os.path.exists(os.path.join(EXAMPLES_DIR, f))]
310
- if samples:
311
- gr.Examples(examples=samples, inputs=img,
312
- label="No specimen handy? Try a sample:")
313
- with gr.Column(scale=1, elem_classes="eink-screen"):
314
- out = gr.HTML(_idle())
315
- gr.HTML(QUOTE_BAR)
316
- gr.Markdown(SAFETY_NOTICE, elem_id="notice")
317
-
318
- btn.click(identify, inputs=img, outputs=out)
319
- img.change(identify, inputs=img, outputs=out)
 
 
 
 
 
320
 
321
  if __name__ == "__main__":
322
  # theme/css belong in launch() in Gradio 6. ssr_mode=False is also enforced via
 
17
 
18
  from pipeline.convergence import build_result
19
  from pipeline.infer import Pipeline
20
+ from game.ui import build_game_tab
21
 
22
  HERE = os.path.dirname(os.path.abspath(__file__))
23
  EXAMPLES_DIR = os.path.join(HERE, "examples")
 
286
  padding:10px 14px; margin-top:10px; font-size:.74rem !important; line-height:1.55 !important;
287
  color:var(--ink2) !important; }
288
  #notice * { font-size:.74rem !important; color:var(--ink2) !important; }
289
+
290
+ /* ── tabs ───────────────────────────────────────────────────── */
291
+ .gradio-container .tab-nav { border-bottom:2px solid var(--bronze) !important; gap:0 !important; }
292
+ .gradio-container .tab-nav button { color:var(--ink2) !important; font-weight:700 !important;
293
+ letter-spacing:.14em !important; font-size:.8rem !important; background:transparent !important;
294
+ border:none !important; border-radius:0 !important; padding:10px 16px !important; }
295
+ .gradio-container .tab-nav button.selected { color:var(--copper) !important;
296
+ border-bottom:3px solid var(--copper) !important; background:var(--panel) !important; }
297
+
298
+ /* ── Beat the Machine ───────────────────────────────────────── */
299
+ .gm-intro { border:2px solid var(--bronze); background:var(--panel); padding:12px 16px;
300
+ margin-bottom:12px; box-shadow:5px 5px 0 rgba(122,63,26,.4); }
301
+ .gm-intro-h { font-size:1.05rem; font-weight:700; letter-spacing:.16em; color:var(--copper); }
302
+ .gm-intro-b { font-size:.82rem; line-height:1.55; color:var(--ink2); margin-top:6px; }
303
+
304
+ .gm-btn { border-radius:0 !important; font-weight:700 !important; letter-spacing:.12em !important;
305
+ border:3px solid var(--ink2) !important; background:var(--panel) !important; color:var(--ink) !important;
306
+ box-shadow:4px 4px 0 rgba(122,63,26,.3) !important; }
307
+ .gm-safe { border-color:#2f6b2b !important; color:#2f6b2b !important; }
308
+ .gm-caut { border-color:#87671c !important; color:#87671c !important; }
309
+ .gm-dead { border-color:#8c1d14 !important; color:#8c1d14 !important; }
310
+ .gm-btn:hover { background:#efe9dc !important; }
311
+
312
+ .gm-score { text-align:center; margin-top:12px; padding:10px; border:2px dashed var(--bronze);
313
+ background:var(--panel); font-size:1rem; letter-spacing:.08em; color:var(--ink); }
314
+ .gm-score b { font-size:1.3rem; color:var(--copper); }
315
+ .gm-vs { color:var(--ink2); font-size:.8rem; margin:0 6px; }
316
+
317
+ .gm-idle { min-height:330px; display:flex; align-items:center; justify-content:center;
318
+ text-align:center; color:var(--ink2); font-size:.9rem; letter-spacing:.06em; }
319
+ .gm-reveal { padding:28px 18px; min-height:330px; }
320
+ .gm-truth-h { font-size:.66rem; letter-spacing:.24em; color:var(--ink2); }
321
+ .gm-truth { font-size:2rem; font-weight:700; letter-spacing:.06em; line-height:1.1; }
322
+ .gm-species { font-size:.9rem; color:var(--ink2); margin-top:4px; }
323
+ .gm-species i { color:var(--ink2); }
324
+ .gm-chips { display:flex; gap:12px; margin-top:18px; }
325
+ .gm-chip { flex:1; border:2px solid; padding:8px 10px; text-align:center; }
326
+ .gm-chip-h { display:block; font-size:.6rem; letter-spacing:.18em; opacity:.8; }
327
+ .gm-chip-t { display:block; font-size:1.05rem; font-weight:700; margin-top:3px; }
328
+ .gm-flav { margin-top:18px; padding-top:12px; border-top:2px dashed var(--ink2);
329
+ font-size:.95rem; font-weight:700; color:var(--ink); }
330
+ .gm-abst { margin-top:8px; font-size:.8rem; line-height:1.5; color:var(--ink2); }
331
+
332
+ .gm-divider { margin-top:18px; padding:6px 0; border-top:2px solid var(--bronze);
333
+ font-size:.7rem; letter-spacing:.2em; color:var(--copper); font-weight:700; }
334
+ .gm-lb { border:2px solid var(--bronze) !important; }
335
+ .gm-refresh { font-size:.7rem !important; color:var(--ink2) !important; background:transparent !important;
336
+ border:none !important; box-shadow:none !important; }
337
+ .gm-warn { color:#8c1d14; font-weight:700; font-size:.85rem; padding:6px 0; }
338
+ .gm-ok { color:#2f6b2b; font-weight:700; font-size:.85rem; padding:6px 0; }
339
+ .gm-note { color:var(--ink2); font-weight:400; }
340
  """
341
 
342
  with gr.Blocks(title="Forager's Field Station") as demo:
 
349
  " <span>REFUSES BY DEFAULT</span></div>"
350
  "</div>"
351
  )
352
+ with gr.Tabs():
353
+ with gr.Tab("β—§ FIELD STATION"):
354
+ gr.HTML(EXPERTS_PANEL)
355
+ with gr.Row():
356
+ with gr.Column(scale=1):
357
+ img = gr.Image(type="pil", label="SPECIMEN", sources=["upload", "webcam"],
358
+ elem_classes="eink-input", height=300)
359
+ btn = gr.Button("β–Έ SCAN SPECIMEN", variant="primary", elem_classes="eink-scan")
360
+ if os.path.isdir(EXAMPLES_DIR):
361
+ samples = [[os.path.join(EXAMPLES_DIR, f)] for f in (
362
+ "chanterelle.jpg", "lions_mane.jpg", "wild_blueberry.jpg",
363
+ "yarrow.jpg", "poison_hemlock.jpg") if os.path.exists(os.path.join(EXAMPLES_DIR, f))]
364
+ if samples:
365
+ gr.Examples(examples=samples, inputs=img,
366
+ label="No specimen handy? Try a sample:")
367
+ with gr.Column(scale=1, elem_classes="eink-screen"):
368
+ out = gr.HTML(_idle())
369
+ gr.HTML(QUOTE_BAR)
370
+ gr.Markdown(SAFETY_NOTICE, elem_id="notice")
371
+
372
+ btn.click(identify, inputs=img, outputs=out)
373
+ img.change(identify, inputs=img, outputs=out)
374
+
375
+ with gr.Tab("βš” BEAT THE MACHINE"):
376
+ build_game_tab(PIPE)
377
 
378
  if __name__ == "__main__":
379
  # theme/css belong in launch() in Gradio 6. ssr_mode=False is also enforced via
game/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Beat the Machine β€” the You-vs-AI duel game tab and its data flywheel."""
game/datastore.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ game/datastore.py β€” persistence to the public sightings dataset.
3
+
4
+ Two responsibilities:
5
+ * leaderboard read/write (Phase 1)
6
+ * append_sighting() (Phase 2 β€” the upload flywheel)
7
+
8
+ Both write to HomesteaderLabs/forager-sightings via huggingface_hub, authenticated
9
+ by the HF_TOKEN Space secret. When no token is present (local dev, or the secret
10
+ isn't set yet) everything falls back to in-memory so the game still runs β€” the
11
+ leaderboard just won't persist across restarts until the secret is added.
12
+
13
+ Concurrency note: the leaderboard is a read-modify-write on one jsonl file, so
14
+ simultaneous posts can race (last write wins). Fine at demo scale; revisit with a
15
+ queue or per-row append if it ever matters.
16
+ """
17
+
18
+ import datetime
19
+ import json
20
+ import os
21
+
22
+ DATASET_REPO = "HomesteaderLabs/forager-sightings"
23
+ LICENSE = "CC-BY-4.0"
24
+
25
+ _TOKEN = os.environ.get("HF_TOKEN")
26
+
27
+ # in-memory fallback: contributor -> aggregated score row
28
+ _mem_leaderboard: dict[str, dict] = {}
29
+
30
+
31
+ def persistence_enabled() -> bool:
32
+ return bool(_TOKEN)
33
+
34
+
35
+ def _now() -> str:
36
+ return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
37
+
38
+
39
+ def load_leaderboard() -> list[dict]:
40
+ """Return all aggregated score rows (from the dataset if a token is set, else memory)."""
41
+ if _TOKEN:
42
+ try:
43
+ from huggingface_hub import hf_hub_download
44
+ path = hf_hub_download(DATASET_REPO, "leaderboard.jsonl", repo_type="dataset",
45
+ token=_TOKEN, force_download=True)
46
+ with open(path) as f:
47
+ return [json.loads(line) for line in f if line.strip()]
48
+ except Exception:
49
+ pass
50
+ return list(_mem_leaderboard.values())
51
+
52
+
53
+ def post_score(contributor: str, you_correct: int, total: int, machine_correct: int) -> list[dict]:
54
+ """Add this session's tally to the contributor's cumulative row; persist; return all rows."""
55
+ rows = {r["contributor"]: r for r in load_leaderboard()}
56
+ r = rows.get(contributor, {
57
+ "contributor": contributor, "skill_correct": 0, "skill_total": 0,
58
+ "machine_correct": 0, "contributions": 0,
59
+ })
60
+ r["skill_correct"] += int(you_correct)
61
+ r["skill_total"] += int(total)
62
+ r["machine_correct"] += int(machine_correct)
63
+ r["updated"] = _now()
64
+ rows[contributor] = r
65
+
66
+ if _TOKEN:
67
+ try:
68
+ from huggingface_hub import HfApi
69
+ body = "\n".join(json.dumps(x) for x in rows.values())
70
+ HfApi(token=_TOKEN).upload_file(
71
+ path_or_fileobj=body.encode("utf-8"), path_in_repo="leaderboard.jsonl",
72
+ repo_id=DATASET_REPO, repo_type="dataset",
73
+ commit_message=f"score: {contributor}",
74
+ )
75
+ except Exception:
76
+ _mem_leaderboard.update(rows)
77
+ else:
78
+ _mem_leaderboard.update(rows)
79
+ return list(rows.values())
80
+
81
+
82
+ def append_sighting(image, user_label: str, machine: dict, contributor: str) -> bool:
83
+ """
84
+ Phase 2: commit one contributed photo + metadata row to the dataset.
85
+ `machine` carries the model's call (prediction/confidence/abstained/safety/domain).
86
+ Returns True on persisted write. Stub raises if no token so callers gate on
87
+ persistence_enabled() first.
88
+ """
89
+ if not _TOKEN:
90
+ return False
91
+ from io import BytesIO
92
+ from huggingface_hub import CommitOperationAdd, HfApi
93
+
94
+ ts = _now()
95
+ fname = f"images/{contributor}_{ts.replace(':', '').replace('-', '')}.jpg"
96
+ buf = BytesIO()
97
+ image.convert("RGB").save(buf, format="JPEG", quality=90)
98
+ row = {
99
+ "file_name": fname, "user_label": user_label,
100
+ "machine_prediction": machine.get("species", "unknown"),
101
+ "machine_confidence": round(float(machine.get("confidence", 0.0)), 4),
102
+ "machine_abstained": bool(machine.get("abstained", True)),
103
+ "machine_safety": machine.get("safety", "UNKNOWN"),
104
+ "routed_domain": machine.get("domain", "unknown"),
105
+ "contributor": contributor, "consent": True, "license": LICENSE, "timestamp": ts,
106
+ }
107
+ api = HfApi(token=_TOKEN)
108
+ # append the metadata line to the existing jsonl
109
+ existing = ""
110
+ try:
111
+ from huggingface_hub import hf_hub_download
112
+ with open(hf_hub_download(DATASET_REPO, "metadata.jsonl", repo_type="dataset",
113
+ token=_TOKEN, force_download=True)) as f:
114
+ existing = f.read().rstrip("\n")
115
+ except Exception:
116
+ pass
117
+ new_meta = (existing + "\n" if existing else "") + json.dumps(row) + "\n"
118
+ api.create_commit(
119
+ repo_id=DATASET_REPO, repo_type="dataset",
120
+ commit_message=f"sighting: {row['machine_prediction']} by {contributor}",
121
+ operations=[
122
+ CommitOperationAdd(path_in_repo=fname, path_or_fileobj=buf.getvalue()),
123
+ CommitOperationAdd(path_in_repo="metadata.jsonl",
124
+ path_or_fileobj=new_meta.encode("utf-8")),
125
+ ],
126
+ )
127
+ return True
game/deck.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ [
2
+ {"file": "chanterelle.jpg", "species": "chanterelles_edible"},
3
+ {"file": "lions_mane.jpg", "species": "lions_mane"},
4
+ {"file": "wild_blueberry.jpg", "species": "blueberry_wild"},
5
+ {"file": "yarrow.jpg", "species": "yarrow"},
6
+ {"file": "poison_hemlock.jpg", "species": "poison_hemlock_deadly"}
7
+ ]
game/deck.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ game/deck.py β€” the curated duel deck (cards with real ground truth).
3
+
4
+ Phase-1 deck reuses the bundled validated example photos; each card's truth tier
5
+ is resolved from the same SPECIES_METADATA the live pipeline uses, so the game and
6
+ the model are graded against one source of truth. Grow the deck by dropping more
7
+ labelled images in examples/ and adding rows to deck.json.
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import random
13
+
14
+ from pipeline.metadata import SPECIES_METADATA, UNKNOWN_META
15
+
16
+ _HERE = os.path.dirname(__file__)
17
+ _EXAMPLES_DIR = os.path.join(_HERE, "..", "examples")
18
+
19
+
20
+ def _load_deck() -> list[dict]:
21
+ with open(os.path.join(_HERE, "deck.json")) as f:
22
+ raw = json.load(f)
23
+ deck = []
24
+ for c in raw:
25
+ path = os.path.join(_EXAMPLES_DIR, c["file"])
26
+ if not os.path.exists(path):
27
+ continue
28
+ meta = SPECIES_METADATA.get(c["species"], UNKNOWN_META)
29
+ deck.append({
30
+ "file": c["file"],
31
+ "path": path,
32
+ "species": c["species"],
33
+ "tier": meta["safety"], # SAFE | CAUTION | DEADLY
34
+ "scientific": meta["scientific"],
35
+ })
36
+ return deck
37
+
38
+
39
+ DECK = _load_deck()
40
+
41
+
42
+ def random_card(exclude_file: str | None = None) -> dict:
43
+ """Return a random card; avoid repeating the immediately-previous one when possible."""
44
+ pool = [c for c in DECK if c["file"] != exclude_file] or DECK
45
+ return random.choice(pool)
game/ui.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ game/ui.py β€” "Beat the Machine" duel tab.
3
+
4
+ A card from the curated deck is dealt; you call its safety tier (SAFE / CAUTION /
5
+ DEADLY) and the same on-device pipeline calls it at the same instant. Score
6
+ accrues per session; post it to the leaderboard. The machine's edge is that it
7
+ refuses when unsure β€” so the deadly cards punish overconfidence in both players.
8
+ Stay sharper than the machine you built.
9
+ """
10
+
11
+ from functools import partial
12
+
13
+ import gradio as gr
14
+ import pandas as pd
15
+ from PIL import Image
16
+
17
+ from pipeline.convergence import build_result
18
+
19
+ from . import datastore
20
+ from . import deck as deck_mod
21
+
22
+ TIER_COLOR = {"SAFE": "#2f6b2b", "CAUTION": "#87671c", "DEADLY": "#8c1d14", "UNKNOWN": "#57544c"}
23
+ _LB_COLS = ["Player", "You %", "Machine %", "Rounds"]
24
+
25
+
26
+ def _machine_call(pipe, path: str):
27
+ res = build_result(pipe.identify(Image.open(path)))
28
+ tier = "UNKNOWN" if res.abstained else res.safety
29
+ return tier, res
30
+
31
+
32
+ def _scoreboard(score: dict) -> str:
33
+ return (
34
+ f"<div class='gm-score'>"
35
+ f"<span class='gm-you'>YOU&nbsp;<b>{score['you']}</b></span>"
36
+ f"<span class='gm-vs'>/&nbsp;{score['total']}&nbsp;Β·&nbsp;vs&nbsp;Β·</span>"
37
+ f"<span class='gm-mach'>MACHINE&nbsp;<b>{score['machine']}</b></span>"
38
+ f"</div>"
39
+ )
40
+
41
+
42
+ def _pretty(species: str) -> str:
43
+ return species.replace("_toxic", "").replace("_deadly", "").replace("_", " ").title()
44
+
45
+
46
+ def _chip(label: str, tier: str, right: bool) -> str:
47
+ c = TIER_COLOR.get(tier, "#57544c")
48
+ mark = "βœ“" if right else "βœ•"
49
+ return (
50
+ f"<div class='gm-chip' style='border-color:{c};color:{c}'>"
51
+ f"<span class='gm-chip-h'>{label}</span>"
52
+ f"<span class='gm-chip-t'>{tier} {mark}</span></div>"
53
+ )
54
+
55
+
56
+ def _reveal_html(card: dict, you_tier: str, you_right: bool, mtier: str, mach_right: bool) -> str:
57
+ tc = TIER_COLOR[card["tier"]]
58
+ if you_right and not mach_right:
59
+ flav = "πŸ† You beat the machine on this one."
60
+ elif mach_right and not you_right:
61
+ flav = "The machine got you. Study the look-alike."
62
+ elif you_right and mach_right:
63
+ flav = "Dead heat β€” both correct."
64
+ else:
65
+ flav = "Neither nailed it. The woods don't grade on a curve."
66
+ abst = ""
67
+ if mtier == "UNKNOWN":
68
+ abst = ("<div class='gm-abst'>The machine refused to commit β€” that's its whole "
69
+ "point. A refusal beats a confident wrong call.</div>")
70
+ return (
71
+ f"<div class='gm-reveal'>"
72
+ f"<div class='gm-truth-h'>TRUTH</div>"
73
+ f"<div class='gm-truth' style='color:{tc}'>{card['tier']}</div>"
74
+ f"<div class='gm-species'>{_pretty(card['species'])} Β· <i>{card['scientific']}</i></div>"
75
+ f"<div class='gm-chips'>{_chip('YOUR CALL', you_tier, you_right)}"
76
+ f"{_chip('MACHINE', mtier, mach_right)}</div>"
77
+ f"<div class='gm-flav'>{flav}</div>{abst}</div>"
78
+ )
79
+
80
+
81
+ def _lb_df() -> pd.DataFrame:
82
+ data = []
83
+ for r in datastore.load_leaderboard():
84
+ t = int(r.get("skill_total", 0) or 0)
85
+ if t == 0:
86
+ continue
87
+ data.append([
88
+ r.get("contributor", "?"),
89
+ round(100 * r.get("skill_correct", 0) / t),
90
+ round(100 * r.get("machine_correct", 0) / t),
91
+ t,
92
+ ])
93
+ data.sort(key=lambda x: (-x[1], -x[3]))
94
+ return pd.DataFrame(data, columns=_LB_COLS)
95
+
96
+
97
+ def build_game_tab(pipe) -> None:
98
+ """Build the duel tab inside the current gr.Blocks context."""
99
+ gr.HTML(
100
+ "<div class='gm-intro'>"
101
+ "<div class='gm-intro-h'>CAN YOU BEAT THE MACHINE?</div>"
102
+ "<div class='gm-intro-b'>We built the machine. Now try to out-forage it. "
103
+ "A card is dealt β€” call its tier before it does. Remember: the machine's "
104
+ "trick is knowing when <i>not</i> to guess. Every round you play sharpens you "
105
+ "and sharpens the open dataset behind it.</div></div>"
106
+ )
107
+
108
+ score_state = gr.State({"you": 0, "machine": 0, "total": 0})
109
+ card_state = gr.State(None)
110
+
111
+ with gr.Row():
112
+ with gr.Column(scale=1):
113
+ card_img = gr.Image(type="filepath", show_label=False, interactive=False,
114
+ elem_classes="eink-input", height=300)
115
+ deal_btn = gr.Button("β–Έ DEAL A CARD", variant="primary", elem_classes="eink-scan")
116
+ with gr.Row():
117
+ safe_b = gr.Button("SAFE", elem_classes="gm-btn gm-safe")
118
+ caut_b = gr.Button("CAUTION", elem_classes="gm-btn gm-caut")
119
+ dead_b = gr.Button("DEADLY", elem_classes="gm-btn gm-dead")
120
+ with gr.Column(scale=1, elem_classes="eink-screen"):
121
+ reveal = gr.HTML("<div class='gm-idle'>Deal a card to start the duel.</div>")
122
+
123
+ score_html = gr.HTML(_scoreboard({"you": 0, "machine": 0, "total": 0}))
124
+
125
+ gr.HTML("<div class='gm-divider'>β–Œ LEADERBOARD</div>")
126
+ with gr.Row():
127
+ nick = gr.Textbox(label="Name for the leaderboard", placeholder="handle",
128
+ scale=2, elem_classes="eink-input")
129
+ post_btn = gr.Button("POST MY SCORE", elem_classes="eink-scan", scale=1)
130
+ lb_status = gr.HTML("")
131
+ lb = gr.Dataframe(value=_lb_df(), headers=_LB_COLS, interactive=False,
132
+ elem_classes="gm-lb", wrap=True)
133
+ refresh_btn = gr.Button("⟳ refresh", elem_classes="gm-refresh")
134
+
135
+ # ── handlers (closures capture `pipe`) ───────────────────────────────────
136
+ def _deal(score, card):
137
+ prev = card["file"] if card else None
138
+ c = deck_mod.random_card(exclude_file=prev)
139
+ new = {**c, "answered": False}
140
+ return c["path"], new, "<div class='gm-idle'>Your call?</div>", _scoreboard(score)
141
+
142
+ def _guess(tier, card, score):
143
+ if not card or card.get("answered"):
144
+ return gr.update(), card, score # ignore double-taps / no card yet
145
+ mtier, _ = _machine_call(pipe, card["path"])
146
+ you_right = tier == card["tier"]
147
+ mach_right = mtier == card["tier"]
148
+ score = {
149
+ "you": score["you"] + int(you_right),
150
+ "machine": score["machine"] + int(mach_right),
151
+ "total": score["total"] + 1,
152
+ }
153
+ card = {**card, "answered": True}
154
+ return _reveal_html(card, tier, you_right, mtier, mach_right), card, score
155
+
156
+ def _post(nickname, score):
157
+ name = (nickname or "").strip()
158
+ if not name:
159
+ return "<div class='gm-warn'>Enter a name first.</div>", gr.update()
160
+ if score["total"] == 0:
161
+ return "<div class='gm-warn'>Play at least one round first.</div>", gr.update()
162
+ datastore.post_score(name, score["you"], score["total"], score["machine"])
163
+ note = "" if datastore.persistence_enabled() else \
164
+ " <span class='gm-note'>(session-only until the dataset token is set)</span>"
165
+ return f"<div class='gm-ok'>Posted, {name}.{note}</div>", _lb_df()
166
+
167
+ deal_btn.click(_deal, [score_state, card_state], [card_img, card_state, reveal, score_html])
168
+ for b, t in [(safe_b, "SAFE"), (caut_b, "CAUTION"), (dead_b, "DEADLY")]:
169
+ b.click(partial(_guess, t), [card_state, score_state], [reveal, card_state, score_state]) \
170
+ .then(_scoreboard, [score_state], [score_html])
171
+ post_btn.click(_post, [nick, score_state], [lb_status, lb])
172
+ refresh_btn.click(lambda: _lb_df(), None, [lb])