HomesteaderLabs commited on
Commit
b4bd4a7
Β·
verified Β·
1 Parent(s): 74cfe42

Deploy Forager's Field Station

Browse files
Files changed (5) hide show
  1. README.md +1 -0
  2. app.py +6 -1
  3. game/datastore.py +17 -0
  4. game/ui.py +104 -11
  5. 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
- with gr.Row():
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(nickname, score):
160
- name = (nickname or "").strip()
161
- if not name:
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(name, score["you"], score["total"], score["machine"])
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, {name}.{note}</div>", _lb_df()
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, [nick, score_state], [lb_status, lb])
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