Deploy Forager's Field Station
Browse files- README.md +1 -0
- app.py +6 -1
- game/datastore.py +17 -0
- game/ui.py +104 -11
- requirements.txt +1 -1
README.md
CHANGED
|
@@ -8,6 +8,7 @@ sdk_version: 6.16.0
|
|
| 8 |
python_version: "3.11"
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
| 11 |
license: apache-2.0
|
| 12 |
short_description: Pocket-sized intelligence for identifying edible wild foods
|
| 13 |
---
|
|
|
|
| 8 |
python_version: "3.11"
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
+
hf_oauth: true
|
| 12 |
license: apache-2.0
|
| 13 |
short_description: Pocket-sized intelligence for identifying edible wild foods
|
| 14 |
---
|
app.py
CHANGED
|
@@ -17,7 +17,7 @@ import gradio as gr
|
|
| 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")
|
|
@@ -349,6 +349,8 @@ 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)
|
|
@@ -375,6 +377,9 @@ with gr.Blocks(title="Forager's Field Station") as demo:
|
|
| 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
|
| 380 |
# the GRADIO_SSR_MODE Space variable β Gradio 6's Node SSR proxy can't bind port
|
|
|
|
| 17 |
|
| 18 |
from pipeline.convergence import build_result
|
| 19 |
from pipeline.infer import Pipeline
|
| 20 |
+
from game.ui import build_game_tab, build_stump_tab
|
| 21 |
|
| 22 |
HERE = os.path.dirname(os.path.abspath(__file__))
|
| 23 |
EXAMPLES_DIR = os.path.join(HERE, "examples")
|
|
|
|
| 349 |
" <span>REFUSES BY DEFAULT</span></div>"
|
| 350 |
"</div>"
|
| 351 |
)
|
| 352 |
+
with gr.Row(elem_id="loginbar"):
|
| 353 |
+
gr.LoginButton(value="βΈ Sign in with Hugging Face to post & contribute")
|
| 354 |
with gr.Tabs():
|
| 355 |
with gr.Tab("β§ FIELD STATION"):
|
| 356 |
gr.HTML(EXPERTS_PANEL)
|
|
|
|
| 377 |
with gr.Tab("β BEAT THE MACHINE"):
|
| 378 |
build_game_tab(PIPE)
|
| 379 |
|
| 380 |
+
with gr.Tab("β STUMP THE MACHINE"):
|
| 381 |
+
build_stump_tab(PIPE)
|
| 382 |
+
|
| 383 |
if __name__ == "__main__":
|
| 384 |
# theme/css belong in launch() in Gradio 6. ssr_mode=False is also enforced via
|
| 385 |
# the GRADIO_SSR_MODE Space variable β Gradio 6's Node SSR proxy can't bind port
|
game/datastore.py
CHANGED
|
@@ -79,6 +79,23 @@ def post_score(contributor: str, you_correct: int, total: int, machine_correct:
|
|
| 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.
|
|
|
|
| 79 |
return list(rows.values())
|
| 80 |
|
| 81 |
|
| 82 |
+
def load_contributors() -> list[dict]:
|
| 83 |
+
"""Count accepted sightings per contributor from metadata.jsonl (the Contributor board)."""
|
| 84 |
+
if not _TOKEN:
|
| 85 |
+
return []
|
| 86 |
+
try:
|
| 87 |
+
from collections import Counter
|
| 88 |
+
from huggingface_hub import hf_hub_download
|
| 89 |
+
path = hf_hub_download(DATASET_REPO, "metadata.jsonl", repo_type="dataset",
|
| 90 |
+
token=_TOKEN, force_download=True)
|
| 91 |
+
with open(path) as f:
|
| 92 |
+
rows = [json.loads(line) for line in f if line.strip()]
|
| 93 |
+
counts = Counter(r.get("contributor", "?") for r in rows)
|
| 94 |
+
return [{"contributor": k, "count": v} for k, v in counts.most_common()]
|
| 95 |
+
except Exception:
|
| 96 |
+
return []
|
| 97 |
+
|
| 98 |
+
|
| 99 |
def append_sighting(image, user_label: str, machine: dict, contributor: str) -> bool:
|
| 100 |
"""
|
| 101 |
Phase 2: commit one contributed photo + metadata row to the dataset.
|
game/ui.py
CHANGED
|
@@ -126,10 +126,7 @@ def build_game_tab(pipe) -> None:
|
|
| 126 |
score_html = gr.HTML(_scoreboard({"you": 0, "machine": 0, "total": 0}))
|
| 127 |
|
| 128 |
gr.HTML("<div class='gm-divider'>β LEADERBOARD</div>")
|
| 129 |
-
|
| 130 |
-
nick = gr.Textbox(label="Name for the leaderboard", placeholder="handle",
|
| 131 |
-
scale=2, elem_classes="eink-input")
|
| 132 |
-
post_btn = gr.Button("POST MY SCORE", elem_classes="eink-scan", scale=1)
|
| 133 |
lb_status = gr.HTML("")
|
| 134 |
lb = gr.Dataframe(value=_lb_df(), headers=_LB_COLS, interactive=False,
|
| 135 |
elem_classes="gm-lb", wrap=True)
|
|
@@ -156,20 +153,116 @@ def build_game_tab(pipe) -> None:
|
|
| 156 |
card = {**card, "answered": True}
|
| 157 |
return _reveal_html(card, tier, you_right, mtier, mach_right), card, score
|
| 158 |
|
| 159 |
-
def _post(
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
return "<div class='gm-warn'>Enter a name first.</div>", gr.update()
|
| 163 |
if score["total"] == 0:
|
| 164 |
return "<div class='gm-warn'>Play at least one round first.</div>", gr.update()
|
| 165 |
-
datastore.post_score(
|
| 166 |
note = "" if datastore.persistence_enabled() else \
|
| 167 |
" <span class='gm-note'>(session-only until the dataset token is set)</span>"
|
| 168 |
-
return f"<div class='gm-ok'>Posted
|
| 169 |
|
| 170 |
deal_btn.click(_deal, [score_state, card_state], [card_img, card_state, reveal, score_html])
|
| 171 |
for b, t in [(safe_b, "SAFE"), (caut_b, "CAUTION"), (dead_b, "DEADLY")]:
|
| 172 |
b.click(partial(_guess, t), [card_state, score_state], [reveal, card_state, score_state]) \
|
| 173 |
.then(_scoreboard, [score_state], [score_html])
|
| 174 |
-
post_btn.click(_post, [
|
| 175 |
refresh_btn.click(lambda: _lb_df(), None, [lb])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
score_html = gr.HTML(_scoreboard({"you": 0, "machine": 0, "total": 0}))
|
| 127 |
|
| 128 |
gr.HTML("<div class='gm-divider'>β LEADERBOARD</div>")
|
| 129 |
+
post_btn = gr.Button("POST MY SCORE", elem_classes="eink-scan")
|
|
|
|
|
|
|
|
|
|
| 130 |
lb_status = gr.HTML("")
|
| 131 |
lb = gr.Dataframe(value=_lb_df(), headers=_LB_COLS, interactive=False,
|
| 132 |
elem_classes="gm-lb", wrap=True)
|
|
|
|
| 153 |
card = {**card, "answered": True}
|
| 154 |
return _reveal_html(card, tier, you_right, mtier, mach_right), card, score
|
| 155 |
|
| 156 |
+
def _post(score, profile: gr.OAuthProfile | None = None):
|
| 157 |
+
if profile is None:
|
| 158 |
+
return "<div class='gm-warn'>Log in (top of the page) to post your score.</div>", gr.update()
|
|
|
|
| 159 |
if score["total"] == 0:
|
| 160 |
return "<div class='gm-warn'>Play at least one round first.</div>", gr.update()
|
| 161 |
+
datastore.post_score(profile.username, score["you"], score["total"], score["machine"])
|
| 162 |
note = "" if datastore.persistence_enabled() else \
|
| 163 |
" <span class='gm-note'>(session-only until the dataset token is set)</span>"
|
| 164 |
+
return f"<div class='gm-ok'>Posted as {profile.username}.{note}</div>", _lb_df()
|
| 165 |
|
| 166 |
deal_btn.click(_deal, [score_state, card_state], [card_img, card_state, reveal, score_html])
|
| 167 |
for b, t in [(safe_b, "SAFE"), (caut_b, "CAUTION"), (dead_b, "DEADLY")]:
|
| 168 |
b.click(partial(_guess, t), [card_state, score_state], [reveal, card_state, score_state]) \
|
| 169 |
.then(_scoreboard, [score_state], [score_html])
|
| 170 |
+
post_btn.click(_post, [score_state], [lb_status, lb])
|
| 171 |
refresh_btn.click(lambda: _lb_df(), None, [lb])
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
# ββ Stump the Machine: upload your own find -> the data flywheel ββββββββββββββ
|
| 175 |
+
_CONTRIB_COLS = ["Contributor", "Sightings"]
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def _contrib_df() -> pd.DataFrame:
|
| 179 |
+
rows = datastore.load_contributors()
|
| 180 |
+
return pd.DataFrame([[r["contributor"], r["count"]] for r in rows], columns=_CONTRIB_COLS)
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def _stump_reject_html() -> str:
|
| 184 |
+
return (
|
| 185 |
+
"<div class='gm-reveal'>"
|
| 186 |
+
"<div class='gm-truth-h'>OUT OF DOMAIN</div>"
|
| 187 |
+
"<div class='gm-truth' style='color:#57544c'>NOT STORED</div>"
|
| 188 |
+
"<div class='gm-species'>The router didn't place this in berry, mushroom, or plant β "
|
| 189 |
+
"so it wasn't added to the dataset. Try a clear photo of a wild plant, mushroom, or berry.</div>"
|
| 190 |
+
"</div>"
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def _stump_result_html(label: str, res, stored: bool) -> str:
|
| 195 |
+
mtier = "UNKNOWN" if res.abstained else res.safety
|
| 196 |
+
c = TIER_COLOR.get(mtier, "#57544c")
|
| 197 |
+
mcall = "refused to commit" if res.abstained else f"{_pretty(res.species)} Β· {mtier}"
|
| 198 |
+
head = "π You stumped the machine!" if res.abstained else "The machine made its call."
|
| 199 |
+
store_line = (
|
| 200 |
+
"<div class='gm-ok'>β Added to the open dataset β thank you. This is exactly the "
|
| 201 |
+
"data that makes the next model better.</div>" if stored else
|
| 202 |
+
"<div class='gm-warn'>Couldn't persist (dataset token not set on the Space).</div>"
|
| 203 |
+
)
|
| 204 |
+
return (
|
| 205 |
+
f"<div class='gm-reveal'>"
|
| 206 |
+
f"<div class='gm-truth-h'>YOUR FIND</div>"
|
| 207 |
+
f"<div class='gm-flav'>{head}</div>"
|
| 208 |
+
f"<div class='gm-species'>you said: <b>{label}</b></div>"
|
| 209 |
+
f"<div class='gm-chips'><div class='gm-chip' style='border-color:{c}'>"
|
| 210 |
+
f"<span class='gm-chip-h' style='color:{c} !important'>MACHINE</span>"
|
| 211 |
+
f"<span class='gm-chip-t' style='color:{c} !important'>{mcall}</span></div></div>"
|
| 212 |
+
f"{store_line}"
|
| 213 |
+
f"<div class='gm-abst'>An ID here is never permission to eat β verify with an expert.</div>"
|
| 214 |
+
f"</div>"
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def build_stump_tab(pipe) -> None:
|
| 219 |
+
"""Upload a real find: the router gates it, the model calls it, and (consented) it
|
| 220 |
+
joins the public dataset. The flywheel β every submission trains the next model."""
|
| 221 |
+
gr.HTML(
|
| 222 |
+
"<div class='gm-intro'>"
|
| 223 |
+
"<div class='gm-intro-h'>STUMP THE MACHINE</div>"
|
| 224 |
+
"<div class='gm-intro-b'>Upload your own find. The machine calls it on the spot β and "
|
| 225 |
+
"when it can't, you've stumped it. Every accepted photo joins the open "
|
| 226 |
+
"<b>CC-BY-4.0</b> dataset that trains the next model. You're not just playing; you're "
|
| 227 |
+
"building the thing.</div></div>"
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
with gr.Row():
|
| 231 |
+
with gr.Column(scale=1):
|
| 232 |
+
up = gr.Image(type="pil", sources=["upload", "webcam"], label="YOUR FIND",
|
| 233 |
+
elem_classes="eink-input", height=300)
|
| 234 |
+
guess = gr.Textbox(label="What do you think it is?", placeholder="e.g. chanterelle",
|
| 235 |
+
elem_classes="eink-input")
|
| 236 |
+
consent = gr.Checkbox(
|
| 237 |
+
value=False,
|
| 238 |
+
label="Contribute this photo to the public CC-BY-4.0 dataset (HomesteaderLabs/forager-sightings).")
|
| 239 |
+
submit = gr.Button("βΈ SUBMIT FIND", variant="primary", elem_classes="eink-scan")
|
| 240 |
+
with gr.Column(scale=1, elem_classes="eink-screen"):
|
| 241 |
+
sresult = gr.HTML("<div class='gm-idle'>Upload a find, add your guess, and submit.</div>")
|
| 242 |
+
|
| 243 |
+
gr.HTML("<div class='gm-divider'>β TOP CONTRIBUTORS</div>")
|
| 244 |
+
contrib = gr.Dataframe(value=_contrib_df(), headers=_CONTRIB_COLS, interactive=False,
|
| 245 |
+
elem_classes="gm-lb", wrap=True)
|
| 246 |
+
crefresh = gr.Button("β³ refresh", elem_classes="gm-refresh")
|
| 247 |
+
|
| 248 |
+
def _submit(image, user_label, consented, profile: gr.OAuthProfile | None = None):
|
| 249 |
+
if profile is None:
|
| 250 |
+
return "<div class='gm-warn'>Log in (top of the page) to contribute.</div>", gr.update()
|
| 251 |
+
if image is None:
|
| 252 |
+
return "<div class='gm-warn'>Upload a photo first.</div>", gr.update()
|
| 253 |
+
if not (user_label or "").strip():
|
| 254 |
+
return "<div class='gm-warn'>Add your guess first.</div>", gr.update()
|
| 255 |
+
if not consented:
|
| 256 |
+
return "<div class='gm-warn'>Check the consent box to contribute.</div>", gr.update()
|
| 257 |
+
call = pipe.identify(image)
|
| 258 |
+
res = build_result(call)
|
| 259 |
+
in_domain = (not res.abstained) or call.get("reason", "") == "low_confidence"
|
| 260 |
+
if not in_domain:
|
| 261 |
+
return _stump_reject_html(), gr.update()
|
| 262 |
+
machine = {"species": res.species, "confidence": res.confidence,
|
| 263 |
+
"abstained": res.abstained, "safety": res.safety, "domain": res.domain}
|
| 264 |
+
stored = datastore.append_sighting(image, user_label.strip(), machine, profile.username)
|
| 265 |
+
return _stump_result_html(user_label.strip(), res, stored), _contrib_df()
|
| 266 |
+
|
| 267 |
+
submit.click(_submit, [up, guess, consent], [sresult, contrib])
|
| 268 |
+
crefresh.click(lambda: _contrib_df(), None, [contrib])
|
requirements.txt
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
gradio==6.16.0
|
| 2 |
onnxruntime==1.18.0
|
| 3 |
numpy<2
|
| 4 |
pillow
|
|
|
|
| 1 |
+
gradio[oauth]==6.16.0
|
| 2 |
onnxruntime==1.18.0
|
| 3 |
numpy<2
|
| 4 |
pillow
|