Groundtruth
NextStep Hacks 2026 · Earth Forward
Identify a species from a photo, then answer the question plant-ID apps skip: is this actually a problem where you are standing, and what should you do about it?
FastAPI backend on Render, static frontend on Vercel, Postgres on Supabase. The interface is built to the Groundtruth design spec: warm ground, one clay action colour, and four status colours that belong to the species rather than to the app.
How identification works
photo ──> Pl@ntNet ──(match)──────────────────────────┐
│ │
└─(404 "not a plant" / low score) │
│ │
└──> Gemini vision ───────────┤
│
v
iNaturalist: native here? ───┤
Curated invasive list ───┤
v
combined status
│
Gemini explainer (grounded
on curated facts when we
have them)
Pl@ntNet returns HTTP 404 when it decides an image is not a plant. That
rejection is used deliberately as a free plant/not-plant router — which is why
no-reject is never sent. Insects, animals and fish fall through to Gemini.
Status is resolved as:
| Condition | Status |
|---|---|
| On the curated list, and iNaturalist does not call it native here | invasive |
| Non-native per iNaturalist, not on the curated list | introduced |
| Native or endemic per iNaturalist | native |
| Nothing conclusive | unknown |
The native guard matters: a curated species photographed inside its own native range is reported as native, not invasive.
Endpoints
| Method | Path | Notes |
|---|---|---|
GET |
/api/health |
Liveness + which env vars are missing. Not rate limited — use it to warm up a sleeping Render instance. |
POST |
/api/identify |
Multipart: photo (required), lat, lng (optional). |
POST |
/api/sightings |
JSON body; returns the saved row with a server-generated created_at. |
GET |
/api/sightings |
Most recent first, capped at 500. |
Interactive docs at /docs.
POST /api/identify response
{
"common_name": "Kudzu",
"scientific_name": "Pueraria montana",
"id_source": "plantnet",
"confidence": 0.907,
"status": "invasive",
"explainer": {
"summary": "...",
"what_to_do": ["...", "..."]
},
"establishment_means": "introduced",
"place_name": "Georgia",
"category": "plant",
"curated_match": true
}
id_source is plantnet, gemini, or none. A photo with no identifiable
organism returns HTTP 200 with status: "unknown" and id_source: "none",
not an error — so the UI can render a helpful card instead of catching an
exception.
Rate limits
Applied to all three API routes; /api/health is exempt.
| Limit | Default | Env var |
|---|---|---|
| Per IP, per minute | 5 | RATE_LIMIT_PER_MIN |
| Per IP, per day | 20 | RATE_LIMIT_PER_DAY |
| Global identifies per day | 300 | GLOBAL_IDENTIFY_PER_DAY |
Rejections return 429 with a Retry-After header.
Two things to know before demo day:
- The 20/day cap applies to
GET /api/sightingstoo. Anyone sharing an IP (conference wifi, several judges on one network) stops being able to load sightings after 20 requests. RaiseRATE_LIMIT_PER_DAYin the Render dashboard if that bites — it takes effect on restart, no code push. - It will throttle you while testing. Six uploads in a minute returns 429.
Counters are in-process and reset when Render spins the service down.
Frontend
Plain HTML/CSS/JS. Tailwind via CDN, Leaflet for the map, no build step.
frontend/
index.html identify: upload, the wait, result card, failure states
map.html community map
css/styles.css icon masks, Leaflet theming, motion
js/config.js API_BASE - the one value you edit after deploying
js/api.js fetch wrappers; normalises errors into kinds
js/identify.js identify page logic and result rendering
js/map.js Leaflet, pins, legend-as-filter, empty vs failed
vercel.json static config
Design rules the code enforces:
- Status is the hero. The verdict band is the headline and always names the place: "Invasive in Georgia", "Native to Georgia".
- Colour is never alone. Every status carries its own glyph, its own word, and its own pin silhouette, so it survives colourblindness and sunlight.
- Clay is the app talking; status colours are the species talking. Errors and system messages never borrow a status colour, or red stops meaning invasive. Upload rejections are deliberately grey.
- Native is not a green warning. No report button, a sage panel instead of a numbered task list, and a closing line. It reads as an answer.
- Log this sighting is solid for invasive, outlined for introduced, and absent for native and unknown - an unverified point would pollute the map.
curated_match: trueearns the "Vetted, not generated" chip, so people can tell vetted guidance from a model improvising about herbicides.
The wait names the three backend steps in the order they actually run, then
fills them with real returned values. The cold-start notice appears at ten
seconds, not at zero - before then the wait is normal and saying otherwise
would invent a problem. Both pages call /api/health on load to warm the
server, so most people never see it.
Three failures that look nothing like each other: a 429 is an 82px
countdown driven by Retry-After; an unreachable server has no countdown and
retries itself; an unidentifiable photo is a normal 200 outcome and says so.
The empty map is fully drawn and working, while the failed map shows no map at
all - at a glance they are unmistakably different.
Deploying
1. Supabase
Run in the SQL editor:
create table public.sightings (
id bigint generated always as identity primary key,
common_name text not null,
scientific_name text not null,
status text not null check (status in ('invasive','introduced','native','unknown')),
latitude double precision not null,
longitude double precision not null,
created_at timestamptz not null default now()
);
alter table public.sightings enable row level security;
RLS on with no policies locks out the anon key entirely. The backend uses
the service_role key, which bypasses RLS by design.
Copy the Project URL and the service_role key from Settings → API.
service_role is a full-access admin credential: Render env vars only, never
in frontend code, never committed.
2. Render
New Web Service → connect this repo.
| Setting | Value |
|---|---|
| Root Directory | (leave blank) |
| Build Command | pip install -r backend/requirements.txt |
| Start Command | cd backend && uvicorn main:app --host 0.0.0.0 --port $PORT |
Root Directory is left blank and the paths carry backend/ instead. Either
approach works; this one keeps the whole configuration in the two commands,
so there is one less dashboard field that can silently drift.
Required env vars: GEMINI_API_KEY, PLANTNET_API_KEY, SUPABASE_URL,
SUPABASE_KEY, ALLOWED_ORIGIN
Optional: GEMINI_MODEL, PLANTNET_PROJECT, PLANTNET_MIN_SCORE,
HTTP_TIMEOUT_SECONDS, MAX_UPLOAD_BYTES, and the three rate-limit vars.
See .env.example for the full list with descriptions.
ALLOWED_ORIGIN is comma-separated, so production and a preview URL can both
be allowed. No trailing slashes. Until it is set, browsers cannot call this API
— that is the safe default, and the startup log says so.
3. Vercel
New Project -> same repo -> Root Directory frontend, Framework Preset
Other, no build command.
Then edit frontend/js/config.js and set API_BASE to your Render URL, with
no trailing slash, and push. This is the one manual wire-up between the two
services.
4. Verify
curl https://YOUR-SERVICE.onrender.com/api/health
missing_config should be empty. Then upload a leaf photo (expect
id_source: "plantnet") and an insect photo (expect id_source: "gemini").
That pair exercises the entire fallback chain.
Design notes
- Supabase over HTTPS, not a Postgres connection string. Render's free tier has no IPv6 outbound and Supabase's free-tier direct connection is IPv6-only, so a connection string fails there in a way that is annoying to diagnose.
- Gemini model is pinned to
gemini-3.5-flash-lite. Flash-Lite carries roughly double the free-tier RPM of standard Flash, and nogemini-flash-lite-latestalias is published. Override withGEMINI_MODELif your key lacks access — check your limits at aistudio.google.com/rate-limit. - The explainer is grounded on curated facts. For species on the list,
the vetted
why_it_mattersandwhat_to_dogo into the prompt, so the model does not improvise advice about herbicides or plants that burn skin. - Every upstream failure degrades rather than crashing. Pl@ntNet, Gemini
and iNaturalist can each be down, slow or rate-limited and
/api/identifystill returns a valid, renderable result. supabase-pyis synchronous, so its calls run in a worker thread viaasyncio.to_threadrather than blocking the event loop.
Curated list
backend/data/invasive_species.json —
38 species: 15 plants, 10 insects, 6 animals, 7 aquatic. Each has
why_it_matters and concrete what_to_do actions, plus aliases covering
taxonomic synonyms so matching survives whichever name a service returns.
Matching is forgiving in one direction only. Subspecies entries such as Apis mellifera scutellata (Africanized honey bee) never match on their binomial alone — telling someone their ordinary garden honey bee is an aggressive invasive would be a real failure.
Log in or sign up for Devpost to join the conversation.