mushroom-hunter / app.py
profplate's picture
Update app.py
90de84d verified
"""
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": "<location name>",
"habitat_summary": "<one sentence on dominant trees / habitat type>",
"mushrooms": [
{{
"common_name": "<name>",
"scientific_name": "<Genus species>",
"edibility": "choice edible | edible | inedible | toxic | deadly",
"fruiting_window": "<e.g., 'April-May'>",
"lookalike_warning": "<short warning or 'no major lookalikes'>"
}}
],
"best_visit_advice": "<one sentence>"
}}
]
}}
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 = "<br>".join(
f"• <i>{m.get('common_name', '?')}</i>" for m in mushrooms[:5]
) or "<i>No likely species this month.</i>"
popup_html = f"""
<div style='font-family: serif; max-width: 260px;'>
<b>{loc['name']}</b><br>
<small>{habitat}</small><br><br>
{species_html}
</div>
"""
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 (
"<div class='no-results'>"
"<p>The forest is quiet here. Try expanding your search radius "
"or checking back in a different season.</p></div>"
)
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"""
<div class='mushroom-entry'>
<div class='mushroom-header'>
<span class='mushroom-name'>{m.get('common_name', '?')}</span>
<span class='mushroom-sci'><i>{m.get('scientific_name', '')}</i></span>
</div>
<div class='mushroom-meta'>
<span class='edibility-pill' style='background:{color}'>{edibility}</span>
<span class='fruiting-window'>{m.get('fruiting_window', '')}</span>
</div>
<div class='mushroom-warning'>⚠ {m.get('lookalike_warning', '')}</div>
</div>
"""
if not mushrooms_html:
mushrooms_html = "<p class='no-mushrooms'>No likely species for this season.</p>"
cards.append(f"""
<div class='location-card'>
<h3 class='location-name'>🍄 {pred.get('name', 'Unknown')}</h3>
<p class='habitat'>{pred.get('habitat_summary', '')}</p>
<div class='mushroom-list'>{mushrooms_html}</div>
<p class='visit-advice'><b>The forest suggests:</b> {pred.get('best_visit_advice', '')}</p>
</div>
""")
return "<div class='cards-container'>" + "".join(cards) + "</div>"
# ---------------------------------------------------------------------------
# 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 (
"<p class='error'>Please enter a ZIP code to begin wandering.</p>",
"",
)
if not GEMINI_API_KEY:
return (
"<p class='error'>This Space is missing its GEMINI_API_KEY secret. "
"If you're the owner, add one in Settings → Variables and Secrets.</p>",
"",
)
# 1. Geocode
try:
geo = geocode_zip(zip_code)
except Exception as e:
return f"<p class='error'>Couldn't reach the geocoder: {e}</p>", ""
if not geo:
return f"<p class='error'>Couldn't find ZIP {zip_code}. Try another?</p>", ""
lat, lng, place_name = geo
# 2. Find public lands
try:
locations = find_public_lands(lat, lng, radius_miles)
except Exception as e:
return f"<p class='error'>Couldn't reach Overpass: {e}</p>", ""
if not locations:
map_html = build_map(lat, lng, [], [])
cards_html = (
"<div class='no-results'>"
"<p>No public lands found within that radius. Try expanding it, "
"or perhaps wander somewhere wilder.</p></div>"
)
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"<p class='error'>The mycelium is tangled: {e}</p>", ""
# 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 = """
<link href="https://fonts.googleapis.com/css2?family=Fraunces:wght@400;500;600&family=Lora:wght@400;500&display=swap" rel="stylesheet">
<div class="app-header">
<h1 class="app-title">🍄 Mycelium Map</h1>
<p class="app-tagline">A whimsical companion for the curious forager</p>
</div>
"""
DISCLAIMER_HTML = """
<div class="disclaimer">
<b>⚠ Forager's Oath:</b> 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.
</div>
"""
# 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()