""" Mycelium Map — A whimsical mushroom-foraging companion. This Gradio app accepts a ZIP code, finds nearby public lands suitable for foraging using free OpenStreetMap services, and uses the Gemini API to generate location-specific mushroom predictions. Architecture: - Geocoding: Zippopotam.us (free, no API key, no rate limit) with Nominatim as a fallback - Public lands: Overpass API (free, no API key) - Mushroom intelligence: Google Gemini 2.5 Flash (free tier) - Map rendering: Folium (Leaflet under the hood) with OpenStreetMap tiles - UI: Gradio with custom earthy CSS The Gemini API key is read from the GEMINI_API_KEY environment variable, which on Hugging Face Spaces is set via Settings → Variables and Secrets. """ import os import json import calendar from datetime import datetime import requests import folium import gradio as gr from google import genai from google.genai import types # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "") ZIPPOPOTAM_URL = "https://api.zippopotam.us" NOMINATIM_URL = "https://nominatim.openstreetmap.org/search" OVERPASS_URL = "https://overpass-api.de/api/interpreter" # Polite User-Agent — required by Nominatim's usage policy and good citizenship # in general. USER_AGENT = "MyceliumMap/1.0 (educational mushroom foraging app)" # Gemini client is created lazily so the app can still load if the key is missing # (we want to show a friendly message instead of crashing on startup). _gemini_client = None def get_gemini_client(): """Lazy-initialize the Gemini client. Raises if no key is configured.""" global _gemini_client if _gemini_client is None: if not GEMINI_API_KEY: raise RuntimeError( "GEMINI_API_KEY is not set. On Hugging Face Spaces, add it " "under Settings → Variables and Secrets." ) _gemini_client = genai.Client(api_key=GEMINI_API_KEY) return _gemini_client # --------------------------------------------------------------------------- # Step 1: Geocode the ZIP code # --------------------------------------------------------------------------- # # We previously used Nominatim, but Hugging Face Spaces share outbound IPs # across many tenants, so the public Nominatim server constantly returns # 429 "Too many requests" — the per-IP rate limit is consumed by other # Spaces, not us. Switching to Zippopotam.us (no key, no rate limit) and # keeping Nominatim as a fallback for resilience. # --------------------------------------------------------------------------- def geocode_zip(zip_code: str, country: str = "us"): """Convert a ZIP/postal code into (lat, lng, display_name). Tries Zippopotam.us first (free, no key, no rate limit) and falls back to Nominatim if that fails. Returns None if both fail. """ zip_code = zip_code.strip() # ----- Primary: Zippopotam.us ----- try: url = f"{ZIPPOPOTAM_URL}/{country.lower()}/{zip_code}" resp = requests.get(url, timeout=10) if resp.status_code == 200: data = resp.json() places = data.get("places", []) if places: p = places[0] lat = float(p["latitude"]) lng = float(p["longitude"]) display = ( f"{p.get('place name', zip_code)}, " f"{p.get('state abbreviation', '')} {zip_code}" ).strip(", ") return lat, lng, display # 404 means the ZIP isn't in Zippopotam's data — fall through to Nominatim. except Exception: # Network error, JSON error, etc. — fall through. pass # ----- Fallback: Nominatim ----- try: params = { "postalcode": zip_code, "country": country, "format": "json", "limit": 1, } headers = {"User-Agent": USER_AGENT} resp = requests.get(NOMINATIM_URL, params=params, headers=headers, timeout=15) if resp.status_code == 200: results = resp.json() if results: hit = results[0] return ( float(hit["lat"]), float(hit["lon"]), hit.get("display_name", zip_code), ) except Exception: pass return None # --------------------------------------------------------------------------- # Step 2: Find public lands via Overpass API # --------------------------------------------------------------------------- def find_public_lands(lat: float, lng: float, radius_miles: float): """Query Overpass for parks, forests, and protected areas near the user. Returns a list of dicts with name, lat, lng, and OSM tags. We dedupe by name since Overpass often returns the same protected area as both a node and a way. """ radius_m = int(radius_miles * 1609.34) # This Overpass query asks for any node, way, or relation with tags that # suggest public natural land. We cast a wide net and let Gemini help # interpret the results downstream. query = f""" [out:json][timeout:25]; ( nwr["leisure"="nature_reserve"](around:{radius_m},{lat},{lng}); nwr["leisure"="park"]["operator"](around:{radius_m},{lat},{lng}); nwr["boundary"="national_park"](around:{radius_m},{lat},{lng}); nwr["boundary"="protected_area"](around:{radius_m},{lat},{lng}); nwr["landuse"="forest"](around:{radius_m},{lat},{lng}); nwr["natural"="wood"]["name"](around:{radius_m},{lat},{lng}); ); out center tags 50; """ response = requests.post(OVERPASS_URL, data={"data": query}, headers={"User-Agent": USER_AGENT}, timeout=40) response.raise_for_status() data = response.json() seen_names = set() locations = [] for el in data.get("elements", []): tags = el.get("tags", {}) name = tags.get("name") if not name or name in seen_names: continue seen_names.add(name) # Get coordinates (nodes have lat/lon directly; ways/relations have center). if "lat" in el and "lon" in el: loc_lat, loc_lng = el["lat"], el["lon"] elif "center" in el: loc_lat, loc_lng = el["center"]["lat"], el["center"]["lon"] else: continue locations.append({ "name": name, "lat": loc_lat, "lng": loc_lng, "tags": tags, }) # Limit to a reasonable number so we don't blow our Gemini quota. return locations[:8] # --------------------------------------------------------------------------- # Step 3: Ask Gemini for mushroom predictions # --------------------------------------------------------------------------- MUSHROOM_PROMPT_TEMPLATE = """You are a knowledgeable but cautious mycology guide writing brief foraging notes for an educational app. The user is near {place_name} and is exploring public lands for {month} foraging. For each location below, return a JSON object describing the likely habitat and 3 to 6 mushroom species that could plausibly fruit there in {month} given the region (latitude {lat:.2f}, longitude {lng:.2f}, ecoregion inferred from coordinates). LOCATIONS: {locations_json} Return ONLY valid JSON in this exact shape, no prose, no markdown fences: {{ "locations": [ {{ "name": "", "habitat_summary": "", "mushrooms": [ {{ "common_name": "", "scientific_name": "", "edibility": "choice edible | edible | inedible | toxic | deadly", "fruiting_window": "", "lookalike_warning": "" }} ], "best_visit_advice": "" }} ] }} Be honest about uncertainty. If a location is unlikely to host foraging mushrooms (urban park with mowed lawns, etc.), say so in habitat_summary and return an empty mushrooms array. NEVER recommend eating without expert ID.""" def get_mushroom_predictions(locations, place_name, lat, lng, month): """Send locations to Gemini and parse the structured response.""" if not locations: return [] client = get_gemini_client() # Strip the OSM tags before sending — the names and coords are enough, # and we don't want to waste tokens on raw OSM metadata. slim_locations = [ {"name": loc["name"], "lat": loc["lat"], "lng": loc["lng"]} for loc in locations ] prompt = MUSHROOM_PROMPT_TEMPLATE.format( place_name=place_name, month=month, lat=lat, lng=lng, locations_json=json.dumps(slim_locations, indent=2), ) response = client.models.generate_content( model="gemini-2.5-flash", contents=prompt, config=types.GenerateContentConfig( temperature=0.7, response_mime_type="application/json", ), ) try: parsed = json.loads(response.text) return parsed.get("locations", []) except json.JSONDecodeError: # If Gemini returns malformed JSON, return what we can salvage. return [] # --------------------------------------------------------------------------- # Step 4: Build the map and result cards # --------------------------------------------------------------------------- def build_map(center_lat, center_lng, locations, predictions): """Build a Folium map with mushroom-themed markers.""" fmap = folium.Map( location=[center_lat, center_lng], zoom_start=10, tiles="OpenStreetMap", ) # Center marker (the user's ZIP). folium.Marker( [center_lat, center_lng], popup="You are here 🌲", icon=folium.Icon(color="green", icon="home"), ).add_to(fmap) # Build a quick lookup so we can attach mushroom info to map popups. pred_by_name = {p["name"]: p for p in predictions} for loc in locations: pred = pred_by_name.get(loc["name"], {}) habitat = pred.get("habitat_summary", "Habitat info unavailable.") mushrooms = pred.get("mushrooms", []) species_html = "
".join( f"• {m.get('common_name', '?')}" for m in mushrooms[:5] ) or "No likely species this month." popup_html = f"""
{loc['name']}
{habitat}

{species_html}
""" folium.Marker( [loc["lat"], loc["lng"]], popup=folium.Popup(popup_html, max_width=300), icon=folium.Icon(color="darkgreen", icon="leaf", prefix="fa"), ).add_to(fmap) return fmap._repr_html_() def build_result_cards(predictions): """Build HTML cards for each location's foraging brief.""" if not predictions: return ( "
" "

The forest is quiet here. Try expanding your search radius " "or checking back in a different season.

" ) cards = [] edibility_colors = { "choice edible": "#7a8b3a", "edible": "#9aab5a", "inedible": "#8a7a5a", "toxic": "#a0522d", "deadly": "#8b2500", } for pred in predictions: mushrooms_html = "" for m in pred.get("mushrooms", []): edibility = m.get("edibility", "unknown").lower() color = edibility_colors.get(edibility, "#8a7a5a") mushrooms_html += f"""
{m.get('common_name', '?')} {m.get('scientific_name', '')}
{edibility} {m.get('fruiting_window', '')}
⚠ {m.get('lookalike_warning', '')}
""" if not mushrooms_html: mushrooms_html = "

No likely species for this season.

" cards.append(f"""

🍄 {pred.get('name', 'Unknown')}

{pred.get('habitat_summary', '')}

{mushrooms_html}

The forest suggests: {pred.get('best_visit_advice', '')}

""") return "
" + "".join(cards) + "
" # --------------------------------------------------------------------------- # Main handler — orchestrates the whole flow # --------------------------------------------------------------------------- def find_mushroom_spots(zip_code, radius_miles, month_name): """The main function wired up to the Gradio button.""" if not zip_code or not zip_code.strip(): return ( "

Please enter a ZIP code to begin wandering.

", "", ) if not GEMINI_API_KEY: return ( "

This Space is missing its GEMINI_API_KEY secret. " "If you're the owner, add one in Settings → Variables and Secrets.

", "", ) # 1. Geocode try: geo = geocode_zip(zip_code) except Exception as e: return f"

Couldn't reach the geocoder: {e}

", "" if not geo: return f"

Couldn't find ZIP {zip_code}. Try another?

", "" lat, lng, place_name = geo # 2. Find public lands try: locations = find_public_lands(lat, lng, radius_miles) except Exception as e: return f"

Couldn't reach Overpass: {e}

", "" if not locations: map_html = build_map(lat, lng, [], []) cards_html = ( "
" "

No public lands found within that radius. Try expanding it, " "or perhaps wander somewhere wilder.

" ) return cards_html, map_html # 3. Ask Gemini for mushroom predictions try: predictions = get_mushroom_predictions(locations, place_name, lat, lng, month_name) except Exception as e: return f"

The mycelium is tangled: {e}

", "" # 4. Build the response map_html = build_map(lat, lng, locations, predictions) cards_html = build_result_cards(predictions) return cards_html, map_html # --------------------------------------------------------------------------- # Custom CSS — earthy, mossy, whimsical # --------------------------------------------------------------------------- CUSTOM_CSS = """ /* Background — parchment with a subtle paper texture via SVG noise */ .gradio-container { background: #f4ead5 !important; background-image: radial-gradient(at 20% 30%, rgba(122, 139, 58, 0.08) 0%, transparent 50%), radial-gradient(at 80% 70%, rgba(160, 82, 45, 0.06) 0%, transparent 50%), url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2'/%3E%3CfeColorMatrix values='0 0 0 0 0.4 0 0 0 0 0.3 0 0 0 0 0.2 0 0 0 0.04 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E") !important; font-family: 'Lora', Georgia, serif !important; color: #3a2f1f !important; } /* Header */ .app-header { text-align: center; padding: 1.5rem 1rem 0.5rem 1rem; } .app-title { font-family: 'Fraunces', 'DM Serif Display', Georgia, serif; font-size: 3rem; font-weight: 600; color: #3d4a1a; margin: 0; letter-spacing: -0.02em; text-shadow: 1px 1px 0 rgba(255, 250, 230, 0.5); } .app-tagline { font-style: italic; color: #6b5d3f; font-size: 1.1rem; margin: 0.25rem 0 0 0; } /* Input panel — looks like an aged label */ .input-panel { background: rgba(255, 248, 225, 0.7) !important; border: 2px solid #8a7a5a !important; border-radius: 6px !important; padding: 1.25rem !important; box-shadow: 2px 3px 0 rgba(0, 0, 0, 0.05) !important; } /* Buttons */ button.primary, button[variant="primary"] { background: #3d4a1a !important; color: #f4ead5 !important; font-family: 'Fraunces', serif !important; font-size: 1.05rem !important; border: none !important; border-radius: 4px !important; padding: 0.6rem 1.2rem !important; letter-spacing: 0.02em !important; transition: all 0.2s !important; } button.primary:hover, button[variant="primary"]:hover { background: #2d3a0a !important; transform: translateY(-1px); box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15) !important; } /* Result cards */ .cards-container { display: flex; flex-direction: column; gap: 1rem; padding: 0.5rem; } .location-card { background: rgba(255, 250, 235, 0.85); border: 1px solid #8a7a5a; border-left: 5px solid #7a8b3a; border-radius: 4px; padding: 1rem 1.25rem; box-shadow: 1px 2px 0 rgba(0, 0, 0, 0.04); } .location-name { font-family: 'Fraunces', serif; color: #3d4a1a; margin: 0 0 0.5rem 0; font-size: 1.4rem; font-weight: 500; } .habitat { color: #5a4a30; font-style: italic; margin: 0 0 0.75rem 0; line-height: 1.4; } .mushroom-list { display: flex; flex-direction: column; gap: 0.6rem; margin: 0.75rem 0; } .mushroom-entry { background: rgba(244, 234, 213, 0.4); border-left: 3px solid #a0522d; padding: 0.5rem 0.75rem; border-radius: 2px; } .mushroom-header { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: baseline; } .mushroom-name { font-weight: 600; color: #3a2f1f; } .mushroom-sci { color: #6b5d3f; font-size: 0.9rem; } .mushroom-meta { margin: 0.25rem 0; display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; } .edibility-pill { color: #f4ead5; padding: 0.15rem 0.6rem; border-radius: 10px; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; } .fruiting-window { color: #6b5d3f; font-size: 0.85rem; font-style: italic; } .mushroom-warning { color: #8b4513; font-size: 0.85rem; margin-top: 0.25rem; } .visit-advice { color: #3d4a1a; margin: 0.75rem 0 0 0; padding-top: 0.5rem; border-top: 1px dashed #c7b88f; font-size: 0.95rem; } .no-results, .no-mushrooms { text-align: center; color: #6b5d3f; font-style: italic; padding: 1rem; } .error { background: rgba(160, 82, 45, 0.15); border-left: 4px solid #a0522d; padding: 0.75rem 1rem; border-radius: 2px; color: #5a2500; } /* Map container styling */ iframe { border-radius: 4px !important; border: 2px solid #8a7a5a !important; box-shadow: 2px 3px 0 rgba(0, 0, 0, 0.05) !important; } /* Subtle warm filter on the map tiles to match the palette */ .folium-map { filter: sepia(0.15) saturate(0.9) brightness(0.98); } /* Disclaimer footer */ .disclaimer { background: rgba(139, 69, 19, 0.1); border: 1px dashed #8b4513; padding: 0.75rem 1rem; border-radius: 4px; color: #5a2500; font-size: 0.9rem; margin-top: 1rem; text-align: center; } .disclaimer b { color: #8b2500; } """ # --------------------------------------------------------------------------- # Gradio interface # --------------------------------------------------------------------------- # Pull in Google Fonts and build the header HTML. HEADER_HTML = """

🍄 Mycelium Map

A whimsical companion for the curious forager

""" DISCLAIMER_HTML = """
⚠ Forager's Oath: Never eat any wild mushroom without 100% positive identification by an expert mycologist. This app is educational only. Many edible species have deadly lookalikes. When in doubt, leave it on the forest floor.
""" # Pre-compute the month list so we always start on the current month. MONTHS = [calendar.month_name[i] for i in range(1, 13)] CURRENT_MONTH = calendar.month_name[datetime.now().month] with gr.Blocks(css=CUSTOM_CSS, title="Mycelium Map") as demo: gr.HTML(HEADER_HTML) with gr.Row(): with gr.Column(scale=1, elem_classes=["input-panel"]): gr.Markdown("### Where shall we wander?") zip_input = gr.Textbox( label="Your ZIP code", placeholder="e.g., 62035", value="", ) radius_input = gr.Slider( label="Search radius (miles)", minimum=5, maximum=50, value=15, step=5, ) month_input = gr.Dropdown( label="Month of the hunt", choices=MONTHS, value=CURRENT_MONTH, ) search_button = gr.Button( "Follow the mycelium →", variant="primary", ) with gr.Column(scale=2): map_output = gr.HTML(label="The land") gr.Markdown("### The forest suggests…") cards_output = gr.HTML() gr.HTML(DISCLAIMER_HTML) search_button.click( fn=find_mushroom_spots, inputs=[zip_input, radius_input, month_input], outputs=[cards_output, map_output], ) if __name__ == "__main__": demo.launch()