Deploy Forager's Field Station
Browse files- app.py +77 -20
- game/__init__.py +1 -0
- game/datastore.py +127 -0
- game/deck.json +7 -0
- game/deck.py +45 -0
- 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.
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 <b>{score['you']}</b></span>"
|
| 36 |
+
f"<span class='gm-vs'>/ {score['total']} Β· vs Β·</span>"
|
| 37 |
+
f"<span class='gm-mach'>MACHINE <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])
|