Spaces:
Configuration error
Configuration error
Upload 8 files
Browse files- README.md +106 -9
- app/location.py +46 -0
- app/plant_id_client.py +46 -0
- app/static/styles.css +1 -0
- app/streamlit_app.py +97 -0
- app/utils.py +20 -0
- requirements.txt +6 -0
- tests/test_smoke.py +4 -0
README.md
CHANGED
|
@@ -1,12 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Plant Identifier – Hyderabad Locator (Python)
|
| 2 |
+
|
| 3 |
+
This project lets you:
|
| 4 |
+
1. Upload a **plant JPG** photo.
|
| 5 |
+
2. Identify the plant using the **Plant.id API** (AI model).
|
| 6 |
+
3. Show **where the plant has been observed in Hyderabad** (using the iNaturalist public API) and plot it on an interactive map.
|
| 7 |
+
|
| 8 |
+
It ships with a **Streamlit UI** (`streamlit_app.py`) so you can run everything locally in a few minutes.
|
| 9 |
+
|
| 10 |
---
|
| 11 |
+
|
| 12 |
+
## Features
|
| 13 |
+
- 📷 Image upload (JPG/JPEG/PNG) via Streamlit
|
| 14 |
+
- 🤖 AI recognition via **Plant.id** (top matches with confidence, scientific name, common names, wiki details when available)
|
| 15 |
+
- 📍 Hyderabad occurrences via **iNaturalist** (observations plotted on a Folium map)
|
| 16 |
+
- 🗺️ Interactive map embedded in the app
|
| 17 |
+
- 🔑 `.env` support for secrets
|
| 18 |
+
|
| 19 |
+
> **Note**: You need a free or paid **Plant.id** API key. Create an account at plant.id and copy your key into `.env` as `PLANT_ID_API_KEY`.
|
| 20 |
+
|
| 21 |
---
|
| 22 |
|
| 23 |
+
## File structure
|
| 24 |
+
|
| 25 |
+
```
|
| 26 |
+
plant-identifier-hyd/
|
| 27 |
+
├─ app/
|
| 28 |
+
│ ├─ streamlit_app.py # Streamlit UI
|
| 29 |
+
│ ├─ plant_id_client.py # Plant.id API client
|
| 30 |
+
│ ├─ location.py # Hyderabad bbox + iNaturalist search
|
| 31 |
+
│ ├─ utils.py # Helpers (image, env)
|
| 32 |
+
│ └─ static/
|
| 33 |
+
│ └─ styles.css # Optional extra styles for Streamlit
|
| 34 |
+
├─ data/
|
| 35 |
+
│ └─ sample_images/ # Put example photos here
|
| 36 |
+
├─ tests/
|
| 37 |
+
│ └─ test_smoke.py # Minimal smoke test
|
| 38 |
+
├─ .env.example # Copy to .env and fill in your API key
|
| 39 |
+
├─ requirements.txt
|
| 40 |
+
└─ README.md
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
---
|
| 44 |
+
|
| 45 |
+
## Quickstart
|
| 46 |
+
|
| 47 |
+
1) **Clone or unzip** this folder.
|
| 48 |
+
2) Create a virtual environment (recommended):
|
| 49 |
+
```bash
|
| 50 |
+
python -m venv .venv
|
| 51 |
+
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
| 52 |
+
```
|
| 53 |
+
3) **Install dependencies**:
|
| 54 |
+
```bash
|
| 55 |
+
pip install -r requirements.txt
|
| 56 |
+
```
|
| 57 |
+
4) **Set your API key**: copy `.env.example` to `.env` and put your Plant.id key.
|
| 58 |
+
5) **Run the app**:
|
| 59 |
+
```bash
|
| 60 |
+
streamlit run app/streamlit_app.py
|
| 61 |
+
```
|
| 62 |
+
6) Open the printed local URL in your browser.
|
| 63 |
+
|
| 64 |
+
---
|
| 65 |
+
|
| 66 |
+
## How it works
|
| 67 |
+
|
| 68 |
+
### Plant identification
|
| 69 |
+
We send the uploaded image to **Plant.id** (`/v3/identification`) and request the top candidates. We show:
|
| 70 |
+
- scientific name
|
| 71 |
+
- common names
|
| 72 |
+
- confidence score
|
| 73 |
+
- a short description if available
|
| 74 |
+
|
| 75 |
+
### Hyderabad locations
|
| 76 |
+
We query **iNaturalist** for recent observations within a Hyderabad bounding box. We then plot markers on a **Folium** map and embed it in Streamlit.
|
| 77 |
+
|
| 78 |
+
> The map shows where people have reported observing the plant in Hyderabad. This does not guarantee the plant is wild there; it simply shows community observations.
|
| 79 |
+
|
| 80 |
+
---
|
| 81 |
+
|
| 82 |
+
## Environment variables
|
| 83 |
+
|
| 84 |
+
Create a `.env` file in the project root:
|
| 85 |
+
|
| 86 |
+
```
|
| 87 |
+
PLANT_ID_API_KEY=your_plant_id_api_key_here
|
| 88 |
+
```
|
| 89 |
+
|
| 90 |
+
---
|
| 91 |
+
|
| 92 |
+
## Notes & Limits
|
| 93 |
+
- API quotas apply (Plant.id & iNaturalist).
|
| 94 |
+
- Identification quality depends on your photo (focus, lighting, background).
|
| 95 |
+
- iNaturalist observations depend on community contributions—some plants may have few or no reports in the Hyderabad area.
|
| 96 |
+
|
| 97 |
+
---
|
| 98 |
+
|
| 99 |
+
## Testing
|
| 100 |
+
|
| 101 |
+
```bash
|
| 102 |
+
pytest -q
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
---
|
| 106 |
+
|
| 107 |
+
## License
|
| 108 |
+
|
| 109 |
+
MIT. For learning and non-commercial demo use. Check Plant.id and iNaturalist terms of service for production use.
|
app/location.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import requests
|
| 2 |
+
from typing import Dict, Any, List, Tuple
|
| 3 |
+
|
| 4 |
+
# Rough bounding box for Hyderabad, India (approximate)
|
| 5 |
+
# swlat, swlng (bottom-left) and nelat, nelng (top-right)
|
| 6 |
+
HYD_BBOX = (17.200, 78.200, 17.650, 78.650)
|
| 7 |
+
|
| 8 |
+
INAT_ENDPOINT = "https://api.inaturalist.org/v1/observations"
|
| 9 |
+
|
| 10 |
+
def hyderabad_bbox() -> Tuple[float, float, float, float]:
|
| 11 |
+
return HYD_BBOX
|
| 12 |
+
|
| 13 |
+
def find_observations_in_hyd(taxon_name: str, per_page: int = 30) -> List[Dict[str, Any]]:
|
| 14 |
+
swlat, swlng, nelat, nelng = HYD_BBOX
|
| 15 |
+
params = {
|
| 16 |
+
"taxon_name": taxon_name,
|
| 17 |
+
"swlat": swlat,
|
| 18 |
+
"swlng": swlng,
|
| 19 |
+
"nelat": nelat,
|
| 20 |
+
"nelng": nelng,
|
| 21 |
+
"order": "desc",
|
| 22 |
+
"order_by": "created_at",
|
| 23 |
+
"per_page": per_page,
|
| 24 |
+
}
|
| 25 |
+
r = requests.get(INAT_ENDPOINT, params=params, timeout=30)
|
| 26 |
+
r.raise_for_status()
|
| 27 |
+
data = r.json()
|
| 28 |
+
return data.get("results", [])
|
| 29 |
+
|
| 30 |
+
def observations_to_markers(observations: List[Dict[str, Any]]):
|
| 31 |
+
markers = []
|
| 32 |
+
for o in observations:
|
| 33 |
+
coords = o.get("geojson", {}).get("coordinates")
|
| 34 |
+
if not coords or len(coords) != 2:
|
| 35 |
+
# fallback
|
| 36 |
+
lat = o.get("location", ",").split(",")[0] if o.get("location") else None
|
| 37 |
+
lng = o.get("location", ",").split(",")[1] if o.get("location") else None
|
| 38 |
+
if lat and lng:
|
| 39 |
+
try:
|
| 40 |
+
markers.append((float(lat), float(lng), o))
|
| 41 |
+
except Exception:
|
| 42 |
+
continue
|
| 43 |
+
continue
|
| 44 |
+
lng, lat = coords # iNat stores as [lng, lat]
|
| 45 |
+
markers.append((lat, lng, o))
|
| 46 |
+
return markers
|
app/plant_id_client.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import requests
|
| 2 |
+
from typing import Dict, Any, List, Optional
|
| 3 |
+
|
| 4 |
+
PLANT_ID_ENDPOINT = "https://api.plant.id/v3/identification"
|
| 5 |
+
|
| 6 |
+
class PlantIdClient:
|
| 7 |
+
def __init__(self, api_key: str):
|
| 8 |
+
self.api_key = api_key
|
| 9 |
+
self.headers = {"Api-Key": self.api_key, "Content-Type": "application/json"}
|
| 10 |
+
|
| 11 |
+
def identify(self, images_b64: List[str], latitude: Optional[float] = None, longitude: Optional[float] = None) -> Dict[str, Any]:
|
| 12 |
+
payload = {
|
| 13 |
+
"images": images_b64,
|
| 14 |
+
"modifiers": ["crops_fast", "similar_images"],
|
| 15 |
+
"plant_details": [
|
| 16 |
+
"common_names",
|
| 17 |
+
"url",
|
| 18 |
+
"name_authority",
|
| 19 |
+
"wiki_description",
|
| 20 |
+
"taxonomy",
|
| 21 |
+
"synonyms"
|
| 22 |
+
],
|
| 23 |
+
}
|
| 24 |
+
if latitude is not None and longitude is not None:
|
| 25 |
+
payload["latitude"] = latitude
|
| 26 |
+
payload["longitude"] = longitude
|
| 27 |
+
|
| 28 |
+
resp = requests.post(PLANT_ID_ENDPOINT, headers=self.headers, json=payload, timeout=60)
|
| 29 |
+
resp.raise_for_status()
|
| 30 |
+
return resp.json()
|
| 31 |
+
|
| 32 |
+
@staticmethod
|
| 33 |
+
def parse_top_candidates(result_json: Dict[str, Any]) -> List[Dict[str, Any]]:
|
| 34 |
+
suggestions = result_json.get("result", {}).get("classification", {}).get("suggestions", [])
|
| 35 |
+
out = []
|
| 36 |
+
for s in suggestions:
|
| 37 |
+
plant = s.get("plant", {})
|
| 38 |
+
details = plant.get("details", {})
|
| 39 |
+
out.append({
|
| 40 |
+
"scientific_name": plant.get("scientific_name"),
|
| 41 |
+
"common_names": details.get("common_names", []),
|
| 42 |
+
"probability": s.get("probability"),
|
| 43 |
+
"wiki": (details.get("wiki_description", {}) or {}).get("value"),
|
| 44 |
+
"url": details.get("url"),
|
| 45 |
+
})
|
| 46 |
+
return out
|
app/static/styles.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
/* Optional extra styles for Streamlit components */
|
app/streamlit_app.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import streamlit as st
|
| 3 |
+
from streamlit_folium import st_folium
|
| 4 |
+
import folium
|
| 5 |
+
|
| 6 |
+
from app.utils import load_image, image_to_base64_jpeg, get_env
|
| 7 |
+
from app.plant_id_client import PlantIdClient
|
| 8 |
+
from app.location import find_observations_in_hyd, observations_to_markers, hyderabad_bbox
|
| 9 |
+
|
| 10 |
+
st.set_page_config(page_title="Plant Identifier – Hyderabad Locator", page_icon="🌿", layout="wide")
|
| 11 |
+
|
| 12 |
+
st.title("🌿 Plant Identifier – Hyderabad Locator")
|
| 13 |
+
st.write("Upload a plant photo, identify it with AI, and see where it was observed in Hyderabad.")
|
| 14 |
+
|
| 15 |
+
with st.sidebar:
|
| 16 |
+
st.header("Settings")
|
| 17 |
+
api_key = st.text_input("Plant.id API Key", value=get_env("PLANT_ID_API_KEY"), type="password")
|
| 18 |
+
st.caption("You can also put this in a `.env` file as `PLANT_ID_API_KEY`.")
|
| 19 |
+
st.divider()
|
| 20 |
+
st.markdown("**Tip:** Better results with a clear leaf/flower close-up, good lighting, and neutral background.")
|
| 21 |
+
|
| 22 |
+
uploaded = st.file_uploader("Upload a plant photo (JPG/PNG)", type=["jpg", "jpeg", "png"])
|
| 23 |
+
|
| 24 |
+
if uploaded and api_key:
|
| 25 |
+
image = load_image(uploaded)
|
| 26 |
+
st.image(image, caption="Uploaded image", use_column_width=True)
|
| 27 |
+
|
| 28 |
+
b64 = image_to_base64_jpeg(image)
|
| 29 |
+
client = PlantIdClient(api_key)
|
| 30 |
+
|
| 31 |
+
with st.spinner("Identifying plant via Plant.id..."):
|
| 32 |
+
try:
|
| 33 |
+
result = client.identify([b64])
|
| 34 |
+
except Exception as e:
|
| 35 |
+
st.error(f"Plant.id API error: {e}")
|
| 36 |
+
st.stop()
|
| 37 |
+
|
| 38 |
+
candidates = client.parse_top_candidates(result)
|
| 39 |
+
if not candidates:
|
| 40 |
+
st.warning("No candidates returned. Try a clearer image.")
|
| 41 |
+
st.stop()
|
| 42 |
+
|
| 43 |
+
st.subheader("Top Matches")
|
| 44 |
+
top = None
|
| 45 |
+
for idx, c in enumerate(candidates, start=1):
|
| 46 |
+
with st.expander(f"{idx}. {c['scientific_name']} (confidence: {c['probability']:.2%})", expanded=(idx == 1)):
|
| 47 |
+
if idx == 1:
|
| 48 |
+
top = c
|
| 49 |
+
st.write("**Scientific name:**", c["scientific_name"] or "—")
|
| 50 |
+
st.write("**Common names:**", ", ".join(c["common_names"]) if c["common_names"] else "—")
|
| 51 |
+
if c["wiki"]:
|
| 52 |
+
st.write("**About:**", c["wiki"])
|
| 53 |
+
if c["url"]:
|
| 54 |
+
st.write(f"[More details]({c['url']})")
|
| 55 |
+
|
| 56 |
+
# Map Hyderabad occurrences for the top candidate
|
| 57 |
+
if top and top.get("scientific_name"):
|
| 58 |
+
st.subheader(f"📍 Observations in Hyderabad: {top['scientific_name']}")
|
| 59 |
+
|
| 60 |
+
with st.spinner("Fetching observations from iNaturalist..."):
|
| 61 |
+
try:
|
| 62 |
+
observations = find_observations_in_hyd(top["scientific_name"], per_page=100)
|
| 63 |
+
except Exception as e:
|
| 64 |
+
st.error(f"iNaturalist API error: {e}")
|
| 65 |
+
observations = []
|
| 66 |
+
|
| 67 |
+
markers = observations_to_markers(observations)
|
| 68 |
+
swlat, swlng, nelat, nelng = hyderabad_bbox()
|
| 69 |
+
|
| 70 |
+
# Create a Folium map centered roughly in Hyderabad
|
| 71 |
+
center_lat = (swlat + nelat) / 2.0
|
| 72 |
+
center_lng = (swlng + nelng) / 2.0
|
| 73 |
+
fmap = folium.Map(location=[center_lat, center_lng], zoom_start=11)
|
| 74 |
+
|
| 75 |
+
for lat, lng, o in markers:
|
| 76 |
+
popup_lines = []
|
| 77 |
+
if o.get("species_guess"):
|
| 78 |
+
popup_lines.append(f"<b>{o['species_guess']}</b>")
|
| 79 |
+
if o.get("observed_on"):
|
| 80 |
+
popup_lines.append(f"Date: {o['observed_on']}")
|
| 81 |
+
if o.get("place_guess"):
|
| 82 |
+
popup_lines.append(f"Place: {o['place_guess']}")
|
| 83 |
+
popup_html = "<br/>".join(popup_lines) if popup_lines else "iNaturalist observation"
|
| 84 |
+
folium.Marker([lat, lng], popup=popup_html).add_to(fmap)
|
| 85 |
+
|
| 86 |
+
# draw bbox
|
| 87 |
+
folium.Rectangle(
|
| 88 |
+
bounds=[[swlat, swlng], [nelat, nelng]],
|
| 89 |
+
fill=False
|
| 90 |
+
).add_to(fmap)
|
| 91 |
+
|
| 92 |
+
st_map = st_folium(fmap, width=1000, height=600)
|
| 93 |
+
|
| 94 |
+
st.caption("Map shows iNaturalist community observations within a Hyderabad bounding box.")
|
| 95 |
+
|
| 96 |
+
else:
|
| 97 |
+
st.info("Enter your Plant.id API key in the sidebar and upload an image to begin.")
|
app/utils.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import base64
|
| 2 |
+
import os
|
| 3 |
+
from io import BytesIO
|
| 4 |
+
from PIL import Image
|
| 5 |
+
from dotenv import load_dotenv
|
| 6 |
+
|
| 7 |
+
load_dotenv()
|
| 8 |
+
|
| 9 |
+
def get_env(key: str, default: str = "") -> str:
|
| 10 |
+
return os.getenv(key, default)
|
| 11 |
+
|
| 12 |
+
def image_to_base64_jpeg(image: Image.Image) -> str:
|
| 13 |
+
"""Convert a PIL image to base64-encoded JPEG string."""
|
| 14 |
+
buf = BytesIO()
|
| 15 |
+
image = image.convert("RGB")
|
| 16 |
+
image.save(buf, format="JPEG", quality=90)
|
| 17 |
+
return base64.b64encode(buf.getvalue()).decode("utf-8")
|
| 18 |
+
|
| 19 |
+
def load_image(file) -> Image.Image:
|
| 20 |
+
return Image.open(file)
|
requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit
|
| 2 |
+
requests
|
| 3 |
+
python-dotenv
|
| 4 |
+
Pillow
|
| 5 |
+
folium
|
| 6 |
+
streamlit-folium
|
tests/test_smoke.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
def test_imports():
|
| 2 |
+
import app.utils
|
| 3 |
+
import app.plant_id_client
|
| 4 |
+
import app.location
|