226 lines
8.7 KiB
Markdown
226 lines
8.7 KiB
Markdown
# 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.py` service file
|
||
- Changes to card scan / OCR flow
|
||
- New Species model fields (no `plantnet_id` in this iteration)
|
||
|
||
---
|
||
|
||
## Architecture
|
||
|
||
### New service: `plants/services/plantnet.py`
|
||
|
||
```python
|
||
identify(images: list[tuple[bytes, str]]) -> list[dict]
|
||
```
|
||
|
||
- Each tuple: `(image_bytes, organ)` where organ is one of `flower | 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:
|
||
```python
|
||
{
|
||
"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 `PlantNetError` on API failure, quota exceeded, or 0 results
|
||
- `PLANTNET_API_KEY` read 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_paths` in 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_matches` not 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_selected` in session
|
||
- Run VPC `search_species(scientific_name)`, take top 3 results → store as `identify_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_selected` not 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):
|
||
|
||
```python
|
||
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_id` field (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
|