""" app.py — Forager's Field Station (Gradio Space). Photograph a wild plant or mushroom; the model identifies it — or refuses when it isn't sure. A domain router + three tf_efficientnet_lite2 experts (~9M params each), the same stack that runs offline on a Hailo NPU in a handheld field device. This Space is styled as that device's copper-and-bronze e-ink readout. Built for the Build Small Hackathon. Safety-first: this is an identification aid, never an authority — SAFE is never presented as permission to eat. """ import os import time import gradio as gr from pipeline.convergence import build_result from pipeline.infer import Pipeline from game.ui import build_game_tab, build_stump_tab HERE = os.path.dirname(os.path.abspath(__file__)) EXAMPLES_DIR = os.path.join(HERE, "examples") PIPE = Pipeline() # Safety tier -> (badge label, accent, glyph). These greens/ambers/reds are the # SAFETY channel and are kept distinct from the copper/bronze brand chrome so the # tier reads instantly. SAFE is never a green light: the label says "verify" and # _card always appends a confirm-with-an-expert line. INK_SAFE, INK_CAUTION, INK_DEADLY, INK_UNK = "#2f6b2b", "#87671c", "#8c1d14", "#57544c" TIER = { "SAFE": ("EDIBLE · VERIFY", INK_SAFE, "✓"), "CAUTION": ("CAUTION", INK_CAUTION, "▲"), "DEADLY": ("DEADLY · DO NOT EAT", INK_DEADLY, "✕"), "UNKNOWN": ("UNKNOWN", INK_UNK, "?"), } DOMAIN_LABEL = {"berry": "Berry", "mushroom": "Mushroom", "plant": "Plant", "other": "Other"} DARWIN = ("It is not the strongest of the species that survive, nor the most " "intelligent, but the one most responsive to change.") def _pretty(species: str) -> str: return species.replace("_toxic", "").replace("_deadly", "").replace("_", " ").title() def _idle(msg: str = "FIELD STATION READY") -> str: return ( f"
" f"
{msg}
" f"
upload, capture, or pick a sample below to scan
" ) def _loading() -> str: """Vine-growing animation shown while inference runs (no quote — too brief to read).""" return """
ANALYZING SPECIMEN
""" def _card(r) -> str: label, color, glyph = TIER.get(r.safety, TIER["UNKNOWN"]) if r.abstained: color, label, glyph = INK_UNK, "UNKNOWN", "?" title, sub = "UNKNOWN", "not confident enough — refusing by default" rows = [ f"
{r.key_diff}
", f"
ROUTER · {DOMAIN_LABEL.get(r.domain, r.domain)} " f"@ {r.confidence*100:.0f}%
", ] else: title, sub = _pretty(r.species), "most likely — not confirmed" rows = [ f"
{r.scientific_name}
", f"
confidence {r.confidence*100:.1f}%
", ] if r.lookalike and r.lookalike != "N/A": rows.append( f"
⚠ deadly look-alike: {r.lookalike}
" f"{r.key_diff}
" ) elif r.key_diff: rows.append(f"
{r.key_diff}
") prefix = "DO NOT EAT. " if r.safety == "DEADLY" else "" rows.append( f"
⚠ {prefix}Confirm with an expert before eating — " f"identification aid, not an authority.
" ) body = "".join(rows) return ( f"
" f"
▌ FIELD READOUT" f" {glyph} {label}
" f"
{title}
" f"
{sub}
" f"
{body}
" f"
" ) def identify(image): """Generator: show the vine animation, then the readout.""" if image is None: yield _idle() return t0 = time.time() yield _loading() result = build_result(PIPE.identify(image)) elapsed = time.time() - t0 if elapsed < 1.6: # let the vine finish drawing time.sleep(1.6 - elapsed) yield _card(result) EXPERTS_PANEL = """
● EXPERTS ONLINE — 4 MODELS
DOMAIN ROUTER
routes to berry · mushroom · plant · other — or abstains when unsure
BERRY EXPERT
11 species · wild berries + toxic look-alikes
HIGH-VALUE EXPERT
11 species · chanterelle · morel · lion's mane · ginseng
MEDICINALS EXPERT
21 species · wild medicinals + deadly look-alikes
""" QUOTE_BAR = ( f"
“{DARWIN}”" f"— Charles Darwin
" ) SAFETY_NOTICE = ( "**Identification aid only — never an authority.** Wild plant and mushroom " "ID carries fatal risk. Do not consume anything based on this output without " "independent verification by a qualified expert. The system refuses by default " "when unsure. Amatoxin poisoning is lethal with no reliable field antidote." ) CSS = """ @import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&family=Caveat:wght@600;700&display=swap'); :root { --paper:#e6e4dd; --panel:#E3DBCA; --ink:#1b1a17; --ink2:#57544c; /* box panels */ --bronze:#7a3f1a; --copper:#b87333; /* HomesteaderLabs chrome */ } .gradio-container, .gradio-container * { font-family:'IBM Plex Mono','Courier New',monospace !important; } .gradio-container { background:var(--paper) !important; color:var(--ink) !important; background-image:repeating-linear-gradient(0deg, rgba(27,26,23,.035) 0 1px, transparent 1px 3px) !important; /* e-ink scanlines */ max-width:940px !important; margin:0 auto !important; } footer { display:none !important; } /* strip Gradio's default block chrome + row gutters so every panel shares one width and the columns line up flush with the masthead/experts boxes */ .gradio-container .block { background:transparent !important; border:none !important; box-shadow:none !important; padding:0 !important; box-sizing:border-box !important; } .gradio-container .row { margin:0 !important; } .gradio-container .html-container { padding:0 !important; } .eink-input .image-container, .eink-input .image-frame, .eink-input .wrap, .eink-input .upload-container, .eink-input .empty, .eink-input [data-testid='image'] { background:var(--panel) !important; } /* Gradio's themed labels/upload text were light (built for dark blocks) and turn invisible on the eggshell panels — force them to ink. Leaves the readout card's tier colors, the bronze SCAN button, and the DISPLAY tab untouched. */ .eink-input, .eink-input label, .eink-input .label-wrap, .eink-input span, .eink-input p, .eink-input button:not(.eink-scan) { color:var(--ink) !important; } /* catch-all: the upload dropzone placeholder ("Drop Image Here / - or - / Click to Upload") renders in divs the rule above misses and inherited a near-white theme color — illegible on the eggshell panel. Force every descendant to ink. The .gradio-container prefix matches the theme's specificity so this wins. */ .gradio-container .eink-input * { color:var(--ink) !important; } .eink-screen { color:var(--ink); } /* ── masthead ───────────────────────────────────────────────── */ #masthead { border:3px solid var(--bronze); background:var(--panel); padding:14px 18px; margin-bottom:12px; box-shadow:7px 7px 0 rgba(122,63,26,.55); } #masthead .brand { font-size:.7rem; letter-spacing:.34em; color:var(--bronze); font-weight:700; } #masthead .title { font-size:1.7rem; font-weight:700; letter-spacing:.04em; line-height:1.05; margin-top:2px; color:var(--copper); } #masthead .tag { font-family:'Caveat',cursive !important; font-size:1.4rem; color:var(--bronze); margin-top:0; transform:rotate(-1.2deg); display:inline-block; } #masthead .strip { margin-top:10px; padding-top:8px; border-top:2px dashed var(--bronze); font-size:.66rem; letter-spacing:.16em; color:var(--ink2); display:flex; justify-content:space-between; } /* ── experts panel (collapsible) ────────────────────────────── */ #experts { border:2px solid var(--bronze); background:var(--panel); padding:10px 14px; margin-bottom:12px; box-shadow:5px 5px 0 rgba(122,63,26,.4); } #experts .ex-head { font-size:.68rem; letter-spacing:.22em; color:var(--copper); font-weight:700; margin-bottom:8px; } #experts .ex-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(230px,1fr)); gap:6px 18px; } #experts details.ex { border-left:4px solid var(--copper); padding:2px 0 2px 9px; } #experts summary { cursor:pointer; list-style:none; font-size:.76rem; letter-spacing:.08em; color:var(--ink); font-weight:700; display:flex; align-items:center; gap:7px; } #experts summary::-webkit-details-marker { display:none; } #experts summary::before { content:'▸'; color:var(--copper); font-size:.7rem; } #experts details[open] summary::before { content:'▾'; } #experts .ex-body { font-size:.7rem; color:var(--ink2); padding:4px 0 2px 14px; line-height:1.4; } /* ── controls ───────────────────────────────────────────────── */ .eink-input, .eink-screen { border:3px solid var(--bronze) !important; background:var(--panel) !important; box-shadow:7px 7px 0 rgba(122,63,26,.55) !important; border-radius:0 !important; } .eink-input { padding:6px !important; } button.eink-scan { background:var(--bronze) !important; color:var(--paper) !important; border:3px solid var(--bronze) !important; border-radius:0 !important; font-weight:700 !important; letter-spacing:.18em !important; box-shadow:5px 5px 0 rgba(122,63,26,.4) !important; } button.eink-scan:hover { background:var(--copper) !important; border-color:var(--copper) !important; } .eink-screen { padding:0 !important; position:relative; min-height:330px; } .eink-screen::before { content:'▌ DISPLAY'; position:absolute; top:-3px; left:-3px; background:var(--bronze); color:var(--paper); font-size:.6rem; letter-spacing:.2em; padding:3px 8px; z-index:2; } /* ── readout card ───────────────────────────────────────────── */ .rdt { background:var(--panel); border-left:10px solid var(--accent); padding:34px 18px 18px; min-height:330px; } .rdt-top { display:flex; justify-content:space-between; align-items:center; border-bottom:2px solid var(--ink); padding-bottom:7px; margin-bottom:12px; font-size:.7rem; letter-spacing:.14em; } .rdt-tag { color:var(--ink2); font-weight:600; } .rdt-badge { color:var(--accent); font-weight:700; } .rdt-title { font-size:1.55rem; font-weight:700; line-height:1.15; color:var(--ink); } .rdt-sub { font-size:.7rem; letter-spacing:.1em; color:var(--ink2); margin-top:3px; text-transform:uppercase; } .rdt-body { margin-top:14px; } .rdt-row { line-height:1.6; font-size:.95rem; } .rdt-row i { color:var(--ink2); } .rdt-meta { margin-top:8px; font-size:.72rem; letter-spacing:.1em; color:var(--ink2); } .rdt-diff { color:var(--ink2); font-size:.86rem; } .rdt-look { margin-top:12px; padding:10px 12px; border:2px solid #8c1d14; background:rgba(140,29,20,.06); color:#8c1d14; font-size:.9rem; line-height:1.5; } .rdt-confirm { display:block; margin-top:14px; padding-top:10px; border-top:2px dashed var(--ink); color:var(--accent); font-weight:700; font-size:.9rem; line-height:1.5; } /* idle / standby */ .rdt-idle { min-height:330px; display:flex; flex-direction:column; align-items:center; justify-content:center; text-align:center; color:var(--ink2); padding:24px; } .rdt-idle-glyph { font-size:3rem; opacity:.5; color:var(--bronze); } .rdt-idle-msg { margin-top:10px; font-size:1rem; font-weight:700; letter-spacing:.2em; color:var(--ink) !important; } .rdt-idle-sub { margin-top:6px; font-size:.74rem; letter-spacing:.08em; color:var(--ink2) !important; opacity:1; } /* ── loading: vine only ─────────────────────────────────────── */ .loading { min-height:330px; display:flex; flex-direction:column; align-items:center; justify-content:center; } .vine { width:92px; height:170px; } .vine .stem { fill:none; stroke:var(--bronze); stroke-width:3.6; stroke-linecap:round; stroke-dasharray:320; stroke-dashoffset:320; animation:grow 1.35s ease-out forwards; } .vine .leaf { fill:var(--copper); opacity:0; transform-box:fill-box; transform-origin:center; animation:leafin .5s ease-out forwards; } .vine .bud { fill:var(--copper); opacity:0; transform-box:fill-box; transform-origin:center; animation:leafin .5s ease-out 1.1s forwards; } @keyframes grow { to { stroke-dashoffset:0; } } @keyframes leafin { from { opacity:0; transform:scale(0); } to { opacity:1; transform:scale(1); } } @keyframes fadein { from { opacity:0; } to { opacity:1; } } .scan-label { margin-top:12px; font-size:.72rem; letter-spacing:.24em; color:var(--copper); font-weight:700; opacity:0; animation:fadein .6s ease-out .2s forwards; } /* ── Darwin quote bar (static, readable) ────────────────────── */ #quotebar { text-align:center; margin-top:16px; padding:14px 12px 4px; border-top:2px dashed var(--bronze); } #quotebar .q { font-family:'Caveat',cursive !important; font-size:1.5rem; color:var(--bronze); line-height:1.3; display:block; max-width:620px; margin:0 auto; } #quotebar .q-by { font-size:.64rem; letter-spacing:.2em; color:var(--ink2); } /* safety footer */ #notice { border:2px dashed var(--bronze) !important; background:transparent !important; padding:10px 14px; margin-top:10px; font-size:.74rem !important; line-height:1.55 !important; color:var(--ink2) !important; } #notice * { font-size:.74rem !important; color:var(--ink2) !important; } /* ── tabs ───────────────────────────────────────────────────── */ .gradio-container .tab-nav { border-bottom:2px solid var(--bronze) !important; gap:0 !important; } .gradio-container .tab-nav button { color:var(--ink2) !important; font-weight:700 !important; letter-spacing:.14em !important; font-size:.8rem !important; background:transparent !important; border:none !important; border-radius:0 !important; padding:10px 16px !important; } .gradio-container .tab-nav button.selected { color:var(--copper) !important; border-bottom:3px solid var(--copper) !important; background:var(--panel) !important; } /* ── Beat the Machine ───────────────────────────────────────── */ .gm-intro { border:2px solid var(--bronze); background:var(--panel); padding:12px 16px; margin-bottom:12px; box-shadow:5px 5px 0 rgba(122,63,26,.4); } .gm-intro-h { font-size:1.05rem; font-weight:700; letter-spacing:.16em; color:var(--copper); } .gm-intro-b { font-size:.82rem; line-height:1.55; color:var(--ink2); margin-top:6px; } .gm-btn { border-radius:0 !important; font-weight:700 !important; letter-spacing:.12em !important; border:3px solid var(--ink2) !important; background:var(--panel) !important; color:var(--ink) !important; box-shadow:4px 4px 0 rgba(122,63,26,.3) !important; } .gm-safe { border-color:#2f6b2b !important; color:#2f6b2b !important; } .gm-caut { border-color:#87671c !important; color:#87671c !important; } .gm-dead { border-color:#8c1d14 !important; color:#8c1d14 !important; } .gm-btn:hover { background:#efe9dc !important; } .gm-score { text-align:center; margin-top:12px; padding:10px; border:2px dashed var(--bronze); background:var(--panel); font-size:1rem; letter-spacing:.08em; color:var(--ink); } .gm-score b { font-size:1.3rem; color:var(--copper); } .gm-vs { color:var(--ink2); font-size:.8rem; margin:0 6px; } .gm-idle { min-height:330px; display:flex; align-items:center; justify-content:center; text-align:center; color:var(--ink2); font-size:.9rem; letter-spacing:.06em; } .gm-reveal { padding:28px 18px; min-height:330px; } .gm-truth-h { font-size:.66rem; letter-spacing:.24em; color:var(--ink2); } .gm-truth { font-size:2rem; font-weight:700; letter-spacing:.06em; line-height:1.1; } .gm-species { font-size:.9rem; color:var(--ink2); margin-top:4px; } .gm-species i { color:var(--ink2); } .gm-chips { display:flex; gap:12px; margin-top:18px; } .gm-chip { flex:1; border:2px solid; padding:8px 10px; text-align:center; } .gm-chip-h { display:block; font-size:.6rem; letter-spacing:.18em; opacity:.8; } .gm-chip-t { display:block; font-size:1.05rem; font-weight:700; margin-top:3px; } .gm-flav { margin-top:18px; padding-top:12px; border-top:2px dashed var(--ink2); font-size:.95rem; font-weight:700; color:var(--ink); } .gm-abst { margin-top:8px; font-size:.8rem; line-height:1.5; color:var(--ink2); } .gm-divider { margin-top:18px; padding:6px 0; border-top:2px solid var(--bronze); font-size:.7rem; letter-spacing:.2em; color:var(--copper); font-weight:700; } .gm-lb { border:2px solid var(--bronze) !important; } .gm-refresh { font-size:.7rem !important; color:var(--ink2) !important; background:transparent !important; border:none !important; box-shadow:none !important; } .gm-warn { color:#8c1d14; font-weight:700; font-size:.85rem; padding:6px 0; } .gm-ok { color:#2f6b2b; font-weight:700; font-size:.85rem; padding:6px 0; } .gm-note { color:var(--ink2); font-weight:400; } """ with gr.Blocks(title="Forager's Field Station") as demo: gr.HTML( "
" "
HOMESTEADER LABS
" "
FORAGER'S FIELD STATION
" "
Backyard AI · real-world stakes
" "
ROUTER + 3 EXPERTS · ~0.04B PARAMS" " REFUSES BY DEFAULT
" "
" ) with gr.Row(elem_id="loginbar"): gr.LoginButton(value="▸ Sign in with Hugging Face to post & contribute") with gr.Tabs(): with gr.Tab("◧ FIELD STATION"): gr.HTML(EXPERTS_PANEL) with gr.Row(): with gr.Column(scale=1): img = gr.Image(type="pil", label="SPECIMEN", sources=["upload", "webcam"], elem_classes="eink-input", height=300) btn = gr.Button("▸ SCAN SPECIMEN", variant="primary", elem_classes="eink-scan") if os.path.isdir(EXAMPLES_DIR): samples = [[os.path.join(EXAMPLES_DIR, f)] for f in ( "chanterelle.jpg", "lions_mane.jpg", "wild_blueberry.jpg", "yarrow.jpg", "poison_hemlock.jpg") if os.path.exists(os.path.join(EXAMPLES_DIR, f))] if samples: gr.Examples(examples=samples, inputs=img, label="No specimen handy? Try a sample:") with gr.Column(scale=1, elem_classes="eink-screen"): out = gr.HTML(_idle()) gr.HTML(QUOTE_BAR) gr.Markdown(SAFETY_NOTICE, elem_id="notice") btn.click(identify, inputs=img, outputs=out) img.change(identify, inputs=img, outputs=out) with gr.Tab("⚔ BEAT THE MACHINE"): build_game_tab(PIPE) with gr.Tab("⚑ STUMP THE MACHINE"): build_stump_tab(PIPE) if __name__ == "__main__": # theme/css belong in launch() in Gradio 6. ssr_mode=False is also enforced via # the GRADIO_SSR_MODE Space variable — Gradio 6's Node SSR proxy can't bind port # 7860 on this Space, which otherwise strands the app on :7861 (unreachable). demo.launch(theme=gr.themes.Monochrome(), css=CSS, ssr_mode=False)