| """ |
| 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() |
|
|
| |
| |
| |
| |
| 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"<div class='rdt-idle'><div class='rdt-idle-glyph'>β</div>" |
| f"<div class='rdt-idle-msg'>{msg}</div>" |
| f"<div class='rdt-idle-sub'>upload, capture, or pick a sample below to scan</div></div>" |
| ) |
|
|
|
|
| def _loading() -> str: |
| """Vine-growing animation shown while inference runs (no quote β too brief to read).""" |
| return """ |
| <div class='loading'> |
| <svg class='vine' viewBox='0 0 120 220' xmlns='http://www.w3.org/2000/svg'> |
| <path class='stem' d='M60 212 C 40 190, 80 174, 60 152 C 42 132, 82 114, 60 94 |
| C 42 76, 80 58, 60 36 C 50 24, 64 16, 60 8'/> |
| <g transform='translate(44,150) rotate(-38)'><ellipse class='leaf' style='animation-delay:.35s' rx='10' ry='4.3'/></g> |
| <g transform='translate(78,114) rotate(34)'><ellipse class='leaf' style='animation-delay:.55s' rx='10' ry='4.3'/></g> |
| <g transform='translate(42,76) rotate(-34)'><ellipse class='leaf' style='animation-delay:.75s' rx='9' ry='4'/></g> |
| <g transform='translate(78,46) rotate(36)'><ellipse class='leaf' style='animation-delay:.95s' rx='8' ry='3.6'/></g> |
| <circle class='bud' cx='60' cy='8' r='4'/> |
| </svg> |
| <div class='scan-label'>ANALYZING SPECIMEN</div> |
| </div> |
| """ |
|
|
|
|
| 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"<div class='rdt-row'>{r.key_diff}</div>", |
| f"<div class='rdt-meta'>ROUTER Β· {DOMAIN_LABEL.get(r.domain, r.domain)} " |
| f"@ {r.confidence*100:.0f}%</div>", |
| ] |
| else: |
| title, sub = _pretty(r.species), "most likely β not confirmed" |
| rows = [ |
| f"<div class='rdt-row'><i>{r.scientific_name}</i></div>", |
| f"<div class='rdt-row'>confidence <b>{r.confidence*100:.1f}%</b></div>", |
| ] |
| if r.lookalike and r.lookalike != "N/A": |
| rows.append( |
| f"<div class='rdt-look'>β deadly look-alike: <b>{r.lookalike}</b><br>" |
| f"<span class='rdt-diff'>{r.key_diff}</span></div>" |
| ) |
| elif r.key_diff: |
| rows.append(f"<div class='rdt-row rdt-diff'>{r.key_diff}</div>") |
| prefix = "DO NOT EAT. " if r.safety == "DEADLY" else "" |
| rows.append( |
| f"<div class='rdt-confirm'>β {prefix}Confirm with an expert before eating β " |
| f"identification aid, not an authority.</div>" |
| ) |
|
|
| body = "".join(rows) |
| return ( |
| f"<div class='rdt' style='--accent:{color}'>" |
| f" <div class='rdt-top'><span class='rdt-tag'>β FIELD READOUT</span>" |
| f" <span class='rdt-badge'>{glyph} {label}</span></div>" |
| f" <div class='rdt-title'>{title}</div>" |
| f" <div class='rdt-sub'>{sub}</div>" |
| f" <div class='rdt-body'>{body}</div>" |
| f"</div>" |
| ) |
|
|
|
|
| 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: |
| time.sleep(1.6 - elapsed) |
| yield _card(result) |
|
|
|
|
| EXPERTS_PANEL = """ |
| <div id='experts'> |
| <div class='ex-head'>β EXPERTS ONLINE β 4 MODELS</div> |
| <div class='ex-grid'> |
| <details class='ex'><summary>DOMAIN ROUTER</summary> |
| <div class='ex-body'>routes to berry Β· mushroom Β· plant Β· other β or abstains when unsure</div></details> |
| <details class='ex'><summary>BERRY EXPERT</summary> |
| <div class='ex-body'>11 species Β· wild berries + toxic look-alikes</div></details> |
| <details class='ex'><summary>HIGH-VALUE EXPERT</summary> |
| <div class='ex-body'>11 species Β· chanterelle Β· morel Β· lion's mane Β· ginseng</div></details> |
| <details class='ex'><summary>MEDICINALS EXPERT</summary> |
| <div class='ex-body'>21 species Β· wild medicinals + deadly look-alikes</div></details> |
| </div> |
| </div> |
| """ |
|
|
| QUOTE_BAR = ( |
| f"<div id='quotebar'><span class='q'>“{DARWIN}”</span>" |
| f"<span class='q-by'>β Charles Darwin</span></div>" |
| ) |
|
|
| 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( |
| "<div id='masthead'>" |
| " <div class='brand'>HOMESTEADER LABS</div>" |
| " <div class='title'>FORAGER'S FIELD STATION</div>" |
| " <div class='tag'>Backyard AI Β· real-world stakes</div>" |
| " <div class='strip'><span>ROUTER + 3 EXPERTS Β· ~0.04B PARAMS</span>" |
| " <span>REFUSES BY DEFAULT</span></div>" |
| "</div>" |
| ) |
| 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__": |
| |
| |
| |
| demo.launch(theme=gr.themes.Monochrome(), css=CSS, ssr_mode=False) |
|
|