testh / app.py
AntoineR974's picture
test user based
158d72b verified
Raw
History Blame Contribute Delete
27.3 kB
"""
🌿 Plant Watering Planner β€” Gradio App
=======================================
Multi-tab app:
1. My Garden β€” upload a plant photo β†’ classify genus β†’ add to virtual garden
2. Weather β€” 7-day forecast for your location
3. Watering β€” daily watering recommendations based on garden + weather
Dependencies:
pip install gradio torch torchvision transformers pillow requests python-dotenv
Run:
python app.py
"""
import os
import json
import uuid
import datetime
import requests
from pathlib import Path
import gradio as gr
from PIL import Image
from modules.plant import Plant
from modules.weather_utils import did_or_will_rain, last_rained_date, weather_values
from modules.watering import get_watering_frequency, should_water
from utils.geo import city_to_coordinates
# ── Config ─────────────────────────────────────────────────────────────────────
MODEL_PATH = os.getenv("MODEL_PATH", "./plant-classifier") # local fine-tuned model
WEATHER_API_KEY = os.getenv("WEATHER_API_KEY", "") # OpenWeatherMap key
WEATHER_CITY = os.getenv("WEATHER_CITY", "Marseille,FR")
DATA_ROOT = Path("./user_data") # per-user gardens & photos live here
DATA_ROOT.mkdir(exist_ok=True)
# Default fallback (Marseille) β€” overwritten once the user submits a location
LAT = 43.2965
LON = 5.3698
# ── Per-user paths ────────────────────────────────────────────────────────────
def _user_dir(user_id: str) -> Path:
"""Return (and create) the data directory for a given user_id."""
if not user_id:
user_id = "default"
d = DATA_ROOT / user_id
d.mkdir(parents=True, exist_ok=True)
(d / "plant_photos").mkdir(exist_ok=True)
return d
def _garden_file(user_id: str) -> Path:
return _user_dir(user_id) / "garden.json"
def _photos_dir(user_id: str) -> Path:
return _user_dir(user_id) / "plant_photos"
# ── Load model ─────────────────────────────────────────────────────────────────
def load_model():
"""Load the fine-tuned genus classification model."""
# TODO: replace with your actual trained model path / HuggingFace repo
# model = AutoModelForImageClassification.from_pretrained(MODEL_PATH)
# model.eval()
# return model
# raise NotImplementedError("Load your trained model here")
return None # stub β€” replace with real model
# TODO: initialise model globally
# model = load_model()
# ── Garden persistence ──────────────────────────────────────────────────────────
def load_garden(user_id: str) -> list[dict]:
"""Load saved garden from disk."""
# before loading, update the last_watered field for plants that haven't been watered since the last rain date to avoid overwatering after a period of absence
garden = []
garden_file = _garden_file(user_id)
if garden_file.exists():
garden = json.loads(garden_file.read_text())
last_rain_date = last_rained_date(LAT, LON)
if last_rain_date:
for plant in garden:
if plant["last_watered"] is None or datetime.date.fromisoformat(plant["last_watered"]) < last_rain_date:
plant["last_watered"] = last_rain_date.isoformat()
plant["rained"] = True # Mark that this plant has been watered by rain since the last watering date
return garden
def save_garden(garden: list[dict], user_id: str):
"""Persist garden to disk."""
_garden_file(user_id).write_text(json.dumps(garden, indent=2))
# ══════════════════════════════════════════════════════════════════════════════
# TAB 1 β€” MY GARDEN
# ══════════════════════════════════════════════════════════════════════════════
def classify_plant(image: Image.Image) -> tuple[str, float]:
"""Run the classification model on an uploaded image.
Returns:
(genus_name, confidence_score)
"""
# TODO: run actual model inference
# tensor = TRANSFORM(image).unsqueeze(0)
# with torch.no_grad():
# logits = model(tensor).logits
# probs = torch.softmax(logits, dim=-1)
# top_id = probs.argmax().item()
# return model.config.id2label[top_id], probs[0, top_id].item()
# raise NotImplementedError("Connect your model here")
return "Ficus", 0.87 # stub β€” replace with real inference
def get_plant_info(genus: str) -> dict:
"""Return care metadata for a genus.
TODO: replace stub with a real lookup (local JSON, Wikipedia API, OpenAI, etc.)
"""
plant = Plant(genus)
if plant.plant_name is not None:
return {
"watering_frequency_days": plant.watering_frequency,
"sunlight": plant.sunlight,
"soil": plant.soil_type,
"fertilization_type": plant.fertilization_type,
"notes": f"{plant.plant_name} care info. Replace with real data.",
}
return None
def add_plants_to_garden(images, nickname: str, last_watered_date: datetime.date, user_id: str) -> tuple[str, list[tuple]]:
"""Classify one or more uploaded images and add them to the garden.
Args:
images: single PIL image or list of PIL images.
nickname: User-given name for this plant instance.
last_watered_date: The date when the plant was last watered.
user_id: Identifies which user's garden to update.
Returns:
(status_message, gallery_data)
"""
if images is None:
return "⚠️ Please upload at least one photo.", get_gallery_data(user_id)
if not isinstance(images, list):
images = [images]
garden = load_garden(user_id)
added = []
# Take only the first part of the last_watered_date (the date) and ignore the time part, since we only care about the date for watering purposes
if last_watered_date is not None:
last_watered_date = str(last_watered_date).split()[0] # Get the date part
last_watered_date = datetime.datetime.strptime(last_watered_date, "%Y-%m-%d").date() # Convert to datetime.date
photos_dir = _photos_dir(user_id)
for image in images:
genus, confidence = classify_plant(image)
info = get_plant_info(genus)
# Save photo to disk so it persists across restarts
plant_id = datetime.datetime.now().strftime("%Y%m%d_%H%M%S_%f")
photo_path = photos_dir / f"{plant_id}.jpg"
image.save(photo_path, "JPEG")
garden.append({
"id": plant_id,
"nickname": nickname,
"photo": str(photo_path),
"genus": genus,
"confidence": round(confidence * 100, 1),
"added": datetime.date.today().isoformat(),
"last_watered": None if last_watered_date is None else last_watered_date.isoformat(),
"rained": False,
**info,
})
added.append(genus)
save_garden(garden, user_id)
status = f"βœ… Added {len(added)} plant(s): {', '.join(added)}"
return status, get_gallery_data(user_id)
def get_gallery_data(user_id: str) -> list[tuple]:
"""Return (image_path, caption) tuples for the Gradio Gallery."""
garden = load_garden(user_id)
result = []
for p in garden:
photo = p.get("photo", "")
if photo:
photo_path = Path(photo.replace("\\", "/"))
if not photo_path.is_absolute():
photo_path = (Path.cwd() / photo_path).resolve()
else:
photo_path = photo_path.resolve()
if photo_path.exists():
last = p.get("last_watered") or "Never"
if p['nickname']:
caption = f"{p['nickname'] }"
else:
caption = f"{p['genus']}"
result.append((str(photo_path), caption))
return result
def _visible_plants(user_id: str) -> list[dict]:
"""Plants that have a valid photo on disk (mirrors gallery order)."""
visible = []
for p in load_garden(user_id):
photo = p.get("photo", "")
if not photo:
continue
photo_path = Path(photo.replace("\\", "/"))
if not photo_path.is_absolute():
photo_path = (Path.cwd() / photo_path).resolve()
else:
photo_path = photo_path.resolve()
if photo_path.exists():
visible.append(p)
return visible
def on_plant_selected(evt: gr.SelectData, user_id: str) -> str:
"""Return a markdown detail card when a gallery image is clicked."""
visible = _visible_plants(user_id)
if evt.index >= len(visible):
return "_Select a plant to see details._"
p = visible[evt.index]
last = p.get("last_watered") or "Never"
return f"""
## 🌿 {p['genus']}
| Confidence | Sunlight | Soil | Watering |
|------------|----------|------|----------|
| {p['confidence']}% | {p.get('sunlight','β€”')} | {p.get('soil','β€”')} | {p.get('watering_frequency_days','β€”')} days |
| Added | Last Watered |
|--------|-------------|
| {p['added']} | {last + ' (Rain)' if p.get('rained') else last} |
_{p.get('notes', '')}_
"""
def remove_selected_plant(evt: gr.SelectData, user_id: str) -> tuple[str, list[tuple]]:
"""Remove the clicked plant from the garden."""
visible = _visible_plants(user_id)
if evt.index >= len(visible):
return "No plant selected.", get_gallery_data(user_id)
target_id = visible[evt.index]["id"]
garden = [p for p in load_garden(user_id) if p.get("id") != target_id]
save_garden(garden, user_id)
return "πŸ—‘οΈ Plant removed.", get_gallery_data(user_id)
def get_garden_table(user_id: str) -> list[list]:
"""Format the garden as rows for a Gradio Dataframe."""
garden = load_garden(user_id)
return [
[p["nickname"], p["genus"], f"{p['confidence']}%",
p["sunlight"], p["watering_frequency_days"], p["added"]]
for p in garden
]
def get_forecast_7(city: str) -> list[list]:
# convert city to coordinates
coords = city_to_coordinates(city)
if not coords:
return [["β€”", "City not found. Please check the name and try again.", "β€”", "β€”", "β€”"]]
lat, lon = coords
forecast_list = []
today = datetime.date.today()
for i in range(7):
date_str = (today + datetime.timedelta(days=i)).strftime("%A, %B %d") # Monday, Tuesday, etc.
forecast = weather_values(today + datetime.timedelta(days=i), lat, lon)
forecast_list.append([date_str,
forecast.comment,
f"{forecast.temp_max}Β°C / {forecast.temp_min}Β°C",
f"{forecast.precipitation_probability}%",
f"{forecast.wind_speed} km/h"])
return forecast_list
# ══════════════════════════════════════════════════════════════════════════════
# TAB 2 β€” WEATHER
# ══════════════════════════════════════════════════════════════════════════════
# ══════════════════════════════════════════════════════════════════════════════
# TAB 3 β€” WATERING RECOMMENDATIONS
# ══════════════════
def needs_watering(plant: dict, forecast: list[list]) -> bool:
"""Decide if a plant needs water today.
Logic:
- If it hasn't been watered yet, yes.
- If days since last watering >= plant's frequency, yes.
- If rain probability today > 60%, skip (nature will do it).
TODO: extend with soil type, season, temperature thresholds etc.
"""
today = datetime.date.today()
# Check rain forecast for today
if forecast:
today_rain_str = forecast[0][3].replace("%", "").strip()
try:
if float(today_rain_str) > 60:
return False # enough rain expected
except ValueError:
pass
if not plant.get("last_watered"):
return True
last = datetime.date.fromisoformat(plant["last_watered"])
days_since = (today - last).days
return days_since >= 1 # replace with actual frequency if you want to use it, e.g. `>= plant['watering_frequency_days']`
def get_watering_recommendations(user_id: str) -> list[list]:
"""Return a list of watering tasks for today.
Each row: [nickname, genus, last watered, days overdue, action]
"""
garden = load_garden(user_id)
forecast_list = get_forecast_7(WEATHER_CITY)
today = datetime.date.today()
rows = []
for p in garden:
plant = Plant(p["genus"])
last_watered = p.get("last_watered")
if should_water(plant, last_watered, today, LAT, LON):
last = p.get("last_watered") or "Never"
rows.append([p["nickname"], p["genus"], last, "Needs water πŸ’§"])
if not rows:
rows = [["β€”", "β€”", "β€”", "All plants are happy today! 🌿"]]
return rows
# ══════════════════════════════════════════════════════════════════════════════
# GRADIO UI
# ══════════════════════════════════════════════════════════════════════════════
with gr.Blocks(title="🌿 Plant Watering Planner", theme=gr.themes.Soft()) as app:
gr.Markdown("# 🌿 Plant Watering Planner")
gr.Markdown("Identify your plants, track your garden, and never forget to water.")
# ── Per-browser user ID ─────────────────────────────────────────────────────
# Persists in the browser's local storage across reloads (same browser/device).
user_id_state = gr.BrowserState(None)
# Debug: show the current user_id so you can verify it's stable across reloads
with gr.Accordion("πŸ”§ Debug: user session", open=False):
debug_user_id = gr.Textbox(label="user_id (BrowserState)", interactive=False)
def init_user_id(user_id):
if user_id is None:
user_id = str(uuid.uuid4())
return user_id, user_id
app.load(fn=init_user_id, inputs=[user_id_state], outputs=[user_id_state, debug_user_id])
# ── Location gate ────────────────────────────────────────────────────────
# Asked once at startup. Sets the global LAT/LON/WEATHER_CITY used throughout
# the app, then reveals the rest of the UI.
with gr.Group(visible=True) as location_gate:
gr.Markdown("## πŸ“ Where is your garden?")
gr.Markdown("Enter your city to get accurate weather-based watering recommendations.")
location_input = gr.Textbox(label="Location", value=WEATHER_CITY, placeholder="e.g. Paris,FR")
location_submit = gr.Button("Start", variant="primary")
location_error = gr.Markdown()
# ── Main app (hidden until location confirmed) ─────────────────────────────
with gr.Group(visible=False) as main_app:
with gr.Tabs():
# ── Tab 1: My Garden ──────────────────────────────────────────────────
with gr.Tab("🌱 My Garden"):
# Top row β€” upload
gr.Markdown("## Add plants")
gr.Markdown("Upload one or more photos. The model will identify each genus automatically.")
with gr.Row():
upload_images = gr.Image(type="pil", label="Plant photo(s)")
with gr.Column():
add_btn = gr.Button("βž• Add to garden", variant="primary")
add_status = gr.Markdown()
plant_nickname = gr.Textbox(label="Nickname (optional)", placeholder="e.g. 'Living Room Ficus'", scale=1)
# select last watered date (optional, defaults to today or last rain date)
last_watered_date = gr.DateTime(type="datetime", include_time=False, label="Last watered date (Defaults to last rain date)")
gr.Markdown("---")
gr.Markdown("## My garden πŸͺ΄\n_Click a plant to see details, or use the buttons below._")
refresh_gallery_btn = gr.Button("πŸ”„ Refresh garden")
# Gallery β€” horizontal scroll, one row
garden_gallery = gr.Gallery(
value=[],
label="",
show_label=False,
columns=5, # large number forces horizontal single row
rows=1,
height=440,
object_fit="cover",
allow_preview=True,
)
# Detail panel β€” appears on click
plant_detail = gr.Markdown("_Click a plant photo to see its details._")
# if the plant is unselected, the buttons are hidden. When a plant is selected, the buttons appear and the index of the selected plant is stored in `selected_idx` State.
# Action buttons β€” hidden until a plant is selected
with gr.Row(visible=False) as action_row:
water_btn = gr.Button("πŸ’§ Mark selected as watered", variant="primary")
remove_btn = gr.Button("πŸ—‘οΈ Remove selected plant", variant="stop")
return_btn = gr.Button("πŸ”™ Back to gallery")
action_status = gr.Markdown()
# ── Events ────────────────────────────────────────────────────────
# Track which plant is selected (index stored in State)
selected_idx = gr.State(value=None)
def _store_and_show(evt: gr.SelectData, user_id):
"""Update detail panel, reveal action buttons, and return the selected index."""
return on_plant_selected(evt, user_id), evt.index, gr.Row(visible=True)
add_btn.click(
fn=add_plants_to_garden,
inputs=[upload_images, plant_nickname, last_watered_date, user_id_state],
outputs=[add_status, garden_gallery],
)
garden_gallery.select(
fn=_store_and_show,
inputs=[user_id_state],
outputs=[plant_detail, selected_idx, action_row],
)
refresh_gallery_btn.click(
fn=get_gallery_data,
inputs=[user_id_state],
outputs=[garden_gallery],
)
def _mark_watered_by_idx(idx, user_id):
if idx is None:
return "⚠️ Select a plant first.", get_gallery_data(user_id)
visible = _visible_plants(user_id)
if idx >= len(visible):
return "⚠️ Plant not found.", get_gallery_data(user_id)
target_id = visible[idx]["id"]
garden = load_garden(user_id)
genus = ""
for plant in garden:
if plant.get("id") == target_id:
plant["last_watered"] = datetime.date.today().isoformat()
plant["rained"] = False # Mark that this plant has been watered manually since the last rain date
genus = plant["genus"]
save_garden(garden, user_id)
return f"πŸ’§ Marked **{genus}** as watered today.", get_gallery_data(user_id)
def _remove_by_idx(idx, user_id):
if idx is None:
return "⚠️ Select a plant first.", get_gallery_data(user_id)
visible = _visible_plants(user_id)
if idx >= len(visible):
return "⚠️ Plant not found.", get_gallery_data(user_id)
target_id = visible[idx]["id"]
garden = [p for p in load_garden(user_id) if p.get("id") != target_id]
save_garden(garden, user_id)
return "πŸ—‘οΈ Plant removed.", get_gallery_data(user_id)
water_btn.click(
fn=_mark_watered_by_idx,
inputs=[selected_idx, user_id_state],
outputs=[action_status, garden_gallery],
)
remove_btn.click(
fn=_remove_by_idx,
inputs=[selected_idx, user_id_state],
outputs=[action_status, garden_gallery],
)
return_btn.click(
fn=lambda: ("", None, gr.Row(visible=False)),
outputs=[plant_detail, selected_idx, action_row],
)
# ── Tab 2: Weather ────────────────────────────────────────────────────
with gr.Tab("🌀️ Weather"):
gr.Markdown("## 7-day forecast")
gr.Markdown("Stay ahead of the weather to plan your watering schedule.")
with gr.Row():
city_input = gr.Textbox(label="City", value=WEATHER_CITY,
placeholder="e.g. Paris,FR", scale=3)
forecast_btn = gr.Button("πŸ” Get forecast", variant="primary", scale=1)
forecast_table = gr.Dataframe(
headers=["Date", "Conditions", "Temp (max/min)", "Rain probability", "Wind"],
label="Forecast",
interactive=False,
)
forecast_btn.click(
fn= get_forecast_7,
inputs=[city_input],
outputs=[forecast_table],
)
# ── Tab 3: Watering Recommendations ───────────────────────────────────
with gr.Tab("πŸ’§ Watering Recommendations"):
gr.Markdown("## Today's watering tasks πŸ«—πŸͺ΄")
gr.Markdown("Based on your garden and the weather forecast, "
"here are the plants that need water today.")
refresh_btn = gr.Button("πŸ”„ Refresh recommendations", variant="primary")
gr.Markdown("Watering plan")
watering_table = gr.Dataframe(
headers=["Name", "Plant", "Last watered", "Status"],
interactive=False,
)
refresh_btn.click(
fn=get_watering_recommendations,
inputs=[user_id_state],
outputs=[watering_table],
)
# confirmation button for watering action, updates the watering status of the plants
# did you water these plants? click the button to mark them as watered today and refresh the recommendations
confirm_watered_btn = gr.Button("πŸ’§ I watered these plants !", variant="primary")
# update the watering status of the plants in the watering table when the button is clicked
def _confirm_watered(table_data, user_id):
garden = load_garden(user_id)
for name in table_data["Name"]:
nickname = name
for plant in garden:
if plant["nickname"] == nickname:
plant["last_watered"] = datetime.date.today().isoformat()
plant["rained"] = False # Mark that this plant has been watered manually since the last rain date
save_garden(garden, user_id)
return get_watering_recommendations(user_id)
confirm_watered_btn.click(
fn=_confirm_watered,
inputs=[watering_table, user_id_state],
outputs=[watering_table],
)
# ── Location submit handler ─────────────────────────────────────────────────
def on_location_submit(city):
global LAT, LON, WEATHER_CITY
coords = city_to_coordinates(city)
if not coords:
return (
gr.Group(visible=True),
gr.Group(visible=False),
"⚠️ City not found. Please check the name and try again.",
)
LAT, LON = coords
WEATHER_CITY = city
return gr.Group(visible=False), gr.Group(visible=True), ""
location_submit.click(
fn=on_location_submit,
inputs=[location_input],
outputs=[location_gate, main_app, location_error],
).then(
fn=get_gallery_data,
inputs=[user_id_state],
outputs=[garden_gallery],
).then(
fn=get_forecast_7,
inputs=[location_input],
outputs=[forecast_table],
).then(
fn=get_watering_recommendations,
inputs=[user_id_state],
outputs=[watering_table],
).then(
# keep the Weather tab's city box in sync with what was submitted
fn=lambda c: c,
inputs=[location_input],
outputs=[city_input],
)
if __name__ == "__main__":
app.launch(allowed_paths=[str(DATA_ROOT.resolve())])