8.7 KiB
Pl@ntNet Photo Identification — Design Spec
Date: 2026-05-30 Status: Approved
Overview
Replace the Perenual + VPC text-search entry point with a Pl@ntNet photo-identification flow. User photographs an unknown plant, Pl@ntNet returns ranked species matches, and the app enriches the result with VPC (Dutch nursery) data via a per-field picker. Card scan (OCR) flow is unchanged.
Also in scope: fix the misaligned delete button on the plant detail page.
Scope
In scope:
- New 3-step photo ID flow (
/identify/) - Pl@ntNet API service wrapper
- VPC enrichment after species confirm (auto-search by scientific name)
- Field picker UI (source selection per field)
- Nav update: "+" →
/identify/ - Remove Perenual from species_search UI (service file kept, unused)
- Delete button alignment fix on
plant_detail.html .superpowers/added to.gitignore
Out of scope:
- Removing
perenual.pyservice file - Changes to card scan / OCR flow
- New Species model fields (no
plantnet_idin this iteration)
Architecture
New service: plants/services/plantnet.py
identify(images: list[tuple[bytes, str]]) -> list[dict]
- Each tuple:
(image_bytes, organ)where organ is one offlower | leaf | fruit | bark | habit | auto - Calls
POST https://my-api.plantnet.org/v2/identify/all?api-key=<key>&lang=en - Returns up to 5 ranked matches, each:
{ "scientific_name": str, "common_names": list[str], # English + NL if available "score": float, # 0–1 confidence "gbif_id": str | None, "family": str, "genus": str, } - Raises
PlantNetErroron API failure, quota exceeded, or 0 results PLANTNET_API_KEYread from Django settings (env var)
New view module: plants/views/identify.py
Three views, each handling one step of the flow.
URL structure
| URL | View | Method | Purpose |
|---|---|---|---|
/identify/ |
identify_upload |
GET | Render upload form |
/identify/ |
identify_upload |
POST | Save images, call Pl@ntNet, redirect to step 2 |
/identify/confirm/ |
identify_confirm |
GET | Render species picker |
/identify/confirm/ |
identify_confirm |
POST | Store selection, run VPC search, redirect to step 3 |
/identify/fields/ |
identify_fields |
GET | Render field picker |
/identify/fields/ |
identify_fields |
POST | Create Species + Plant, cleanup, redirect to plant detail |
Session keys
| Key | Content | Set at | Cleared at |
|---|---|---|---|
identify_matches |
list of Pl@ntNet match dicts | step 1 POST | step 3 POST |
identify_image_paths |
list of {"path": str, "organ": str} |
step 1 POST | step 3 POST |
identify_selected |
single match dict | step 2 POST | step 3 POST |
identify_vpc_results |
list of VPC result dicts (top 3) | step 2 POST | step 3 POST |
identify_vpc_idx |
int index of selected VPC result (default 0) | step 3 GET (change) | step 3 POST |
Temp file storage
Images saved to plants/temp_identify/<uuid>_<n>.jpg via default_storage. Deleted unconditionally in step 3 POST regardless of outcome (success or exception).
Step 1 — Upload
Template: plants/templates/plants/identify_upload.html
- Multi-photo picker (up to 5). Each photo shows a thumbnail row with:
- 72×72 px thumbnail
- Organ chips inline: 🌸 flower · 🍃 leaf · 🌿 habit · 🍎 fruit · 🪵 bark · ✨ auto
- Active chip highlighted green, tap to change
- ✕ to remove photo
- Default organ:
auto - "Add another photo" dashed button (hidden when 5 photos selected)
- "Identify →" submit button (disabled until ≥1 photo)
- "Or enter name manually" text link →
/plant/add/(existing VPC text search, secondary path) - Full-screen spinner overlay during submit (same pattern as card scan)
POST handling:
- Validate: at least 1 image, JPEG or PNG only, ≤50 MB total
- Save each image to
plants/temp_identify/<uuid>_<n>.jpg - Call
plantnet.identify(images)with per-image organ tags - On success: store
identify_matches+identify_image_pathsin session → redirect to/identify/confirm/ - On
PlantNetError: delete any saved temp images, re-render step 1 with error banner ("Identification failed — try again or enter name manually"). Quota exceeded shown as "Daily limit reached (500/day)".
Step 2 — Species confirm
Template: plants/templates/plants/identify_confirm.html
- Guard: if
identify_matchesnot in session → redirect to/identify/ - Shows ranked matches (up to 5) as selectable cards:
- Scientific name (bold)
- Common name(s)
- Confidence badge (e.g. "94%") — green ≥70%, amber 30–69%, grey <30%
- Species image placeholder (Pl@ntNet does not return images in identify response)
- Top match pre-selected (green border)
- "Use [species name] →" confirm button
- "← Try again" link back to step 1
POST handling:
- Store selected match as
identify_selectedin session - Run VPC
search_species(scientific_name), take top 3 results → store asidentify_vpc_results - If VPC raises or returns empty: store
identify_vpc_results = [](non-blocking) - Redirect to
/identify/fields/
Step 3 — Field picker
Template: plants/templates/plants/identify_fields.html
- Guard: if
identify_selectednot in session → redirect to/identify/
VPC banner
- If VPC results found: green banner showing top match name + "vasteplantencatalogus.nl", with "change" link. Clicking "change" posts
vpc_idx(integer) back to/identify/fields/GET, storing it in the session and re-rendering with the new VPC selection. Other VPC results shown as selectable rows in a small inline list (no JS required). - If no VPC results: amber banner "No VPC data found — using Pl@ntNet only"
Add session key identify_vpc_idx (int, default 0) — index into identify_vpc_results list for the currently selected VPC match.
Two-source fields (common name, scientific name)
Side-by-side tiles. Active tile has green border + "✓ selected" label. Tap inactive tile to switch.
| Field | Pl@ntNet value | VPC value | Default |
|---|---|---|---|
common_name |
English common name (+ NL if available) | Dutch store/cultivar name | VPC |
scientific_name |
Authoritative species name | May include cultivar | Pl@ntNet |
VPC-only care fields (shown as confirmation tiles, not a choice)
Displayed only when VPC match found. Read-only grid of 2 columns:
- Bloom months (dot strip, same as existing month strip)
- Height (cm)
- Sunlight
- Frost hardiness (°C)
- Planting density (/m²)
Plant name input
Free-text, required. Pre-filled with Pl@ntNet scientific name (user edits to their preferred name e.g. "Liatris").
Save
POST looks up existing Species via Species.objects.filter(scientific_name=<exact>).first() — reuses if found, creates otherwise. (scientific_name has no unique constraint in the model.) Creates Plant linked to species. Deletes all temp images (try/finally) and clears all identify_* session keys. Redirects to plant_detail.
Nav changes
base.html — "+" button href changed from {% url 'plant_add' %} to {% url 'identify_upload' %}.
species_search.html — remove the include_perenual checkbox and Perenual toggle. VPC search remains. Page still accessible via "enter name manually" fallback.
Delete button fix
plant_detail.html — current layout has action buttons (Plant card, Crop thumbnail, Delete plant) as separate inline elements without a flex container. Fix: wrap all three in <div class="d-flex flex-wrap gap-2 mt-2">. Delete button keeps btn-outline-danger.
Error handling
| Scenario | Behaviour |
|---|---|
| Pl@ntNet API error / timeout | Re-render step 1 with error banner |
| Pl@ntNet quota exceeded | Re-render step 1: "Daily limit reached (500/day)" |
| 0 results from Pl@ntNet | Re-render step 1: "No match found — try a different photo" |
| Invalid image format/size | Client accept="image/jpeg,image/png" + server re-renders step 1 with validation error |
| VPC search fails | Non-blocking — step 3 renders in no-VPC-match state |
| Direct navigation to step 2/3 without session | Redirect to step 1 |
| Step 3 POST exception (Species/Plant save fails) | Temp files still cleaned up (try/finally) |
Settings
Add to settings.py (read from env):
PLANTNET_API_KEY = env("PLANTNET_API_KEY", default="")
Add PLANTNET_API_KEY to .env on dockerhost.
.gitignore
Add .superpowers/ to project .gitignore.
Out of scope / future
- Perenual removal (service file unused but harmless)
Species.plantnet_idfield (can add in a later migration once data quality is validated)- Caching Pl@ntNet results (500/day is sufficient for personal use)
- Multilingual common names stored per-language on Species model