"""
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 """
"""
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(
""
)
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)