1078 lines
32 KiB
Markdown
1078 lines
32 KiB
Markdown
# Bibliothèque Photo & Identification de Plantes — Plan d'implémentation
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Ajouter une bibliothèque photo centralisée avec identification automatique de plantes via PlantNet (cloud) + YOLOv8 (fallback local dans un container dédié `ai-service`) et cache Redis.
|
|
|
|
**Architecture:** Backend FastAPI existant appelle PlantNet API en premier ; si indisponible, appelle le service `ai-service` interne (YOLOv8). Les résultats sont mis en cache Redis 7 jours. Un nouveau container `ai-service` héberge le modèle YOLO. Vue `BibliothequeView` + composants `PhotoGallery` et `PhotoIdentifyModal`.
|
|
|
|
**Tech Stack:** FastAPI, redis-py, httpx (déjà présent), ultralytics YOLOv8, Vue 3 + Composition API, Tailwind Gruvbox.
|
|
|
|
---
|
|
|
|
## Task 1 : ai-service — Container YOLO FastAPI
|
|
|
|
**Files:**
|
|
- Créer: `ai-service/Dockerfile`
|
|
- Créer: `ai-service/requirements.txt`
|
|
- Créer: `ai-service/main.py`
|
|
- Modifier: `docker-compose.yml`
|
|
|
|
**Étape 1 : Créer `ai-service/requirements.txt`**
|
|
|
|
```
|
|
fastapi==0.115.5
|
|
uvicorn[standard]==0.32.1
|
|
ultralytics==8.3.0
|
|
Pillow==11.1.0
|
|
python-multipart==0.0.12
|
|
```
|
|
|
|
**Étape 2 : Créer `ai-service/main.py`**
|
|
|
|
```python
|
|
import io
|
|
import os
|
|
from typing import List
|
|
|
|
from fastapi import FastAPI, File, UploadFile
|
|
from PIL import Image
|
|
|
|
app = FastAPI(title="AI Plant Detection Service")
|
|
|
|
_model = None
|
|
MODEL_CACHE_DIR = os.environ.get("MODEL_CACHE_DIR", "/models")
|
|
|
|
|
|
def get_model():
|
|
global _model
|
|
if _model is None:
|
|
from ultralytics import YOLO
|
|
os.makedirs(MODEL_CACHE_DIR, exist_ok=True)
|
|
model_path = os.path.join(MODEL_CACHE_DIR, "plant-leaf.pt")
|
|
if not os.path.exists(model_path):
|
|
import subprocess
|
|
subprocess.run([
|
|
"python", "-c",
|
|
f"from ultralyticsplus import YOLO; m=YOLO('foduucom/plant-leaf-detection-and-classification'); m.export(format='torchscript')"
|
|
], check=False)
|
|
# Chargement direct depuis HuggingFace (téléchargé une seule fois)
|
|
_model = YOLO("foduucom/plant-leaf-detection-and-classification")
|
|
return _model
|
|
|
|
|
|
@app.get("/health")
|
|
def health():
|
|
return {"status": "ok"}
|
|
|
|
|
|
@app.post("/detect")
|
|
async def detect(file: UploadFile = File(...)):
|
|
data = await file.read()
|
|
img = Image.open(io.BytesIO(data)).convert("RGB")
|
|
|
|
model = get_model()
|
|
results = model.predict(img, conf=0.25, iou=0.45, verbose=False)
|
|
|
|
detections = []
|
|
if results and results[0].boxes:
|
|
boxes = results[0].boxes
|
|
names = model.names
|
|
for i in range(min(3, len(boxes))):
|
|
cls_id = int(boxes.cls[i].item())
|
|
conf = float(boxes.conf[i].item())
|
|
detections.append({
|
|
"class_name": names[cls_id],
|
|
"confidence": round(conf, 3),
|
|
})
|
|
return detections
|
|
```
|
|
|
|
**Étape 3 : Créer `ai-service/Dockerfile`**
|
|
|
|
```dockerfile
|
|
FROM python:3.11-slim
|
|
|
|
WORKDIR /app
|
|
COPY requirements.txt .
|
|
RUN pip install --no-cache-dir -r requirements.txt
|
|
|
|
COPY main.py .
|
|
|
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8070"]
|
|
```
|
|
|
|
**Étape 4 : Ajouter `ai-service` et `redis` à `docker-compose.yml`**
|
|
|
|
Remplacer le contenu de `docker-compose.yml` par :
|
|
|
|
```yaml
|
|
services:
|
|
backend:
|
|
build: ./backend
|
|
volumes:
|
|
- ./data:/data
|
|
ports:
|
|
- "8060:8060"
|
|
env_file:
|
|
- .env
|
|
environment:
|
|
- TZ=Europe/Paris
|
|
- AI_SERVICE_URL=http://ai-service:8070
|
|
- REDIS_URL=redis://redis:6379
|
|
restart: unless-stopped
|
|
depends_on:
|
|
- redis
|
|
- ai-service
|
|
|
|
ai-service:
|
|
build: ./ai-service
|
|
volumes:
|
|
- yolo_models:/models
|
|
environment:
|
|
- MODEL_CACHE_DIR=/models
|
|
restart: unless-stopped
|
|
|
|
redis:
|
|
image: redis:7-alpine
|
|
volumes:
|
|
- redis_data:/data
|
|
restart: unless-stopped
|
|
|
|
frontend:
|
|
build: ./frontend
|
|
ports:
|
|
- "8061:8061"
|
|
depends_on:
|
|
- backend
|
|
environment:
|
|
- TZ=Europe/Paris
|
|
restart: unless-stopped
|
|
|
|
volumes:
|
|
yolo_models:
|
|
redis_data:
|
|
```
|
|
|
|
**Étape 5 : Tester le build ai-service**
|
|
|
|
```bash
|
|
docker compose build ai-service
|
|
```
|
|
Attendu : build réussi (peut prendre 2-3 min, télécharge ultralytics)
|
|
|
|
**Étape 6 : Commit**
|
|
|
|
```bash
|
|
git add ai-service/ docker-compose.yml
|
|
git commit -m "feat(ai-service): container YOLO FastAPI pour détection plantes"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2 : Redis — service de cache identifications
|
|
|
|
**Files:**
|
|
- Modifier: `backend/requirements.txt`
|
|
- Créer: `backend/app/services/redis_cache.py`
|
|
|
|
**Étape 1 : Ajouter `redis` à `backend/requirements.txt`**
|
|
|
|
Ajouter la ligne :
|
|
```
|
|
redis==5.2.1
|
|
```
|
|
|
|
**Étape 2 : Créer `backend/app/services/redis_cache.py`**
|
|
|
|
```python
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from typing import Optional
|
|
|
|
_client = None
|
|
|
|
|
|
def _get_client():
|
|
global _client
|
|
if _client is None:
|
|
import redis
|
|
url = os.environ.get("REDIS_URL", "redis://localhost:6379")
|
|
_client = redis.from_url(url, decode_responses=True)
|
|
return _client
|
|
|
|
|
|
def cache_key(image_bytes: bytes) -> str:
|
|
return f"identify:{hashlib.sha256(image_bytes).hexdigest()}"
|
|
|
|
|
|
def get(image_bytes: bytes) -> Optional[list]:
|
|
try:
|
|
value = _get_client().get(cache_key(image_bytes))
|
|
return json.loads(value) if value else None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def set(image_bytes: bytes, results: list, ttl: int = 604800) -> None:
|
|
try:
|
|
_get_client().setex(cache_key(image_bytes), ttl, json.dumps(results))
|
|
except Exception:
|
|
pass # cache indisponible → silencieux
|
|
```
|
|
|
|
**Étape 3 : Créer le dossier `backend/app/services/` (si absent)**
|
|
|
|
```bash
|
|
touch backend/app/services/__init__.py
|
|
```
|
|
|
|
**Étape 4 : Commit**
|
|
|
|
```bash
|
|
git add backend/requirements.txt backend/app/services/
|
|
git commit -m "feat(backend): service cache Redis pour identifications"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3 : Services PlantNet et YOLO (dans le backend)
|
|
|
|
**Files:**
|
|
- Créer: `backend/app/services/plantnet.py`
|
|
- Créer: `backend/app/services/yolo_service.py`
|
|
- Modifier: `.env.example`
|
|
|
|
**Étape 1 : Créer `backend/app/services/plantnet.py`**
|
|
|
|
```python
|
|
import os
|
|
from typing import List
|
|
|
|
import httpx
|
|
|
|
PLANTNET_KEY = os.environ.get("PLANTNET_API_KEY", "2b1088cHCJ4c7Cn2Vqq67xfve")
|
|
PLANTNET_URL = "https://my-api.plantnet.org/v2/identify/all"
|
|
|
|
|
|
async def identify(image_bytes: bytes, filename: str = "photo.jpg") -> List[dict]:
|
|
"""Appelle PlantNet et retourne les 3 meilleures identifications."""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
resp = await client.post(
|
|
PLANTNET_URL,
|
|
params={"api-key": PLANTNET_KEY, "nb-results": 3},
|
|
files={"images": (filename, image_bytes, "image/jpeg")},
|
|
data={"organs": ["auto"]},
|
|
)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
except Exception:
|
|
return []
|
|
|
|
results = []
|
|
for r in data.get("results", [])[:3]:
|
|
species = r.get("species", {})
|
|
common_names = species.get("commonNames", [])
|
|
results.append({
|
|
"species": species.get("scientificNameWithoutAuthor", ""),
|
|
"common_name": common_names[0] if common_names else "",
|
|
"confidence": round(r.get("score", 0.0), 3),
|
|
"image_url": (r.get("images", [{}]) or [{}])[0].get("url", {}).get("m", ""),
|
|
})
|
|
return results
|
|
```
|
|
|
|
**Étape 2 : Créer `backend/app/services/yolo_service.py`**
|
|
|
|
```python
|
|
import os
|
|
from typing import List
|
|
|
|
import httpx
|
|
|
|
AI_SERVICE_URL = os.environ.get("AI_SERVICE_URL", "http://localhost:8070")
|
|
|
|
# Mapping class_name YOLO → nom commun français (partiel)
|
|
_NOMS_FR = {
|
|
"Tomato___healthy": "Tomate (saine)",
|
|
"Tomato___Early_blight": "Tomate (mildiou précoce)",
|
|
"Pepper__bell___healthy": "Poivron (sain)",
|
|
"Apple___healthy": "Pommier (sain)",
|
|
"Potato___healthy": "Pomme de terre (saine)",
|
|
"Grape___healthy": "Vigne (saine)",
|
|
"Corn_(maize)___healthy": "Maïs (sain)",
|
|
"Strawberry___healthy": "Fraisier (sain)",
|
|
"Peach___healthy": "Pêcher (sain)",
|
|
}
|
|
|
|
|
|
async def identify(image_bytes: bytes) -> List[dict]:
|
|
"""Appelle l'ai-service interne et retourne les détections YOLO."""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
resp = await client.post(
|
|
f"{AI_SERVICE_URL}/detect",
|
|
files={"file": ("photo.jpg", image_bytes, "image/jpeg")},
|
|
)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
except Exception:
|
|
return []
|
|
|
|
results = []
|
|
for det in data[:3]:
|
|
cls = det.get("class_name", "")
|
|
results.append({
|
|
"species": cls.replace("___", " — ").replace("_", " "),
|
|
"common_name": _NOMS_FR.get(cls, cls.split("___")[0].replace("_", " ")),
|
|
"confidence": det.get("confidence", 0.0),
|
|
"image_url": "",
|
|
})
|
|
return results
|
|
```
|
|
|
|
**Étape 3 : Mettre à jour `.env.example`**
|
|
|
|
Ajouter ces lignes si elles n'existent pas :
|
|
```
|
|
PLANTNET_API_KEY=2b1088cHCJ4c7Cn2Vqq67xfve
|
|
AI_SERVICE_URL=http://ai-service:8070
|
|
REDIS_URL=redis://redis:6379
|
|
```
|
|
|
|
**Étape 4 : Commit**
|
|
|
|
```bash
|
|
git add backend/app/services/plantnet.py backend/app/services/yolo_service.py .env.example
|
|
git commit -m "feat(backend): services PlantNet et YOLO pour identification"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4 : Endpoint `/api/identify`
|
|
|
|
**Files:**
|
|
- Créer: `backend/app/routers/identify.py`
|
|
- Modifier: `backend/app/main.py`
|
|
|
|
**Étape 1 : Créer `backend/app/routers/identify.py`**
|
|
|
|
```python
|
|
from fastapi import APIRouter, File, UploadFile
|
|
from app.services import plantnet, yolo_service, redis_cache
|
|
|
|
router = APIRouter(tags=["identification"])
|
|
|
|
|
|
@router.post("/identify")
|
|
async def identify_plant(file: UploadFile = File(...)):
|
|
image_bytes = await file.read()
|
|
|
|
# 1. Cache Redis
|
|
cached = redis_cache.get(image_bytes)
|
|
if cached is not None:
|
|
return {"source": "cache", "results": cached}
|
|
|
|
# 2. PlantNet (cloud)
|
|
results = await plantnet.identify(image_bytes, file.filename or "photo.jpg")
|
|
|
|
# 3. Fallback YOLO si PlantNet indisponible
|
|
if not results:
|
|
results = await yolo_service.identify(image_bytes)
|
|
source = "yolo"
|
|
else:
|
|
source = "plantnet"
|
|
|
|
# 4. Mettre en cache
|
|
redis_cache.set(image_bytes, results)
|
|
|
|
return {"source": source, "results": results}
|
|
```
|
|
|
|
**Étape 2 : Enregistrer le router dans `backend/app/main.py`**
|
|
|
|
Dans la liste des imports, ajouter `identify` :
|
|
```python
|
|
from app.routers import ( # noqa
|
|
gardens,
|
|
plants,
|
|
plantings,
|
|
tasks,
|
|
settings,
|
|
media,
|
|
tools,
|
|
dictons,
|
|
astuces,
|
|
recoltes,
|
|
lunar,
|
|
meteo,
|
|
identify, # ← ajouter
|
|
)
|
|
```
|
|
|
|
Et ajouter dans la liste `app.include_router(...)` :
|
|
```python
|
|
app.include_router(identify.router, prefix="/api")
|
|
```
|
|
|
|
**Étape 3 : Tester manuellement (backend local)**
|
|
|
|
```bash
|
|
cd backend && uvicorn app.main:app --reload --port 8060
|
|
# Dans un autre terminal :
|
|
curl -s -X POST http://127.0.0.1:8060/api/identify \
|
|
-F "file=@/chemin/vers/une_photo.jpg" | python3 -m json.tool
|
|
```
|
|
Attendu : `{"source": "plantnet"|"yolo"|"cache", "results": [...]}`
|
|
|
|
**Étape 4 : Commit**
|
|
|
|
```bash
|
|
git add backend/app/routers/identify.py backend/app/main.py
|
|
git commit -m "feat(backend): endpoint POST /api/identify PlantNet + YOLO fallback"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5 : Migration Media — champs `identified_*`
|
|
|
|
**Files:**
|
|
- Modifier: `backend/app/models/media.py`
|
|
- Modifier: `backend/app/migrate.py`
|
|
|
|
**Étape 1 : Ajouter les champs à `backend/app/models/media.py`**
|
|
|
|
```python
|
|
from datetime import datetime, timezone
|
|
from typing import Optional
|
|
from sqlmodel import Field, SQLModel
|
|
|
|
|
|
class Media(SQLModel, table=True):
|
|
id: Optional[int] = Field(default=None, primary_key=True)
|
|
entity_type: str # jardin|plante|outil|plantation
|
|
entity_id: int
|
|
url: str
|
|
thumbnail_url: Optional[str] = None
|
|
titre: Optional[str] = None
|
|
# Identification automatique
|
|
identified_species: Optional[str] = None
|
|
identified_common: Optional[str] = None
|
|
identified_confidence: Optional[float] = None
|
|
identified_source: Optional[str] = None # "plantnet" | "yolo" | "cache"
|
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
```
|
|
|
|
**Étape 2 : Ajouter la migration des colonnes dans `backend/app/migrate.py`**
|
|
|
|
Trouver la fonction qui applique les migrations par table et ajouter pour la table `media` :
|
|
|
|
```python
|
|
# Dans la section où on migre les tables existantes,
|
|
# ajouter ces colonnes si elles n'existent pas :
|
|
_add_column_if_missing(conn, "media", "identified_species", "TEXT")
|
|
_add_column_if_missing(conn, "media", "identified_common", "TEXT")
|
|
_add_column_if_missing(conn, "media", "identified_confidence", "REAL")
|
|
_add_column_if_missing(conn, "media", "identified_source", "TEXT")
|
|
```
|
|
|
|
(Utilise la fonction helper `_add_column_if_missing` déjà présente dans migrate.py)
|
|
|
|
**Étape 3 : Rebuilder le backend et vérifier**
|
|
|
|
```bash
|
|
docker compose up --build -d backend
|
|
docker compose logs backend --tail=20
|
|
```
|
|
Attendu : démarrage sans erreur, migrations appliquées.
|
|
|
|
**Étape 4 : Commit**
|
|
|
|
```bash
|
|
git add backend/app/models/media.py backend/app/migrate.py
|
|
git commit -m "feat(backend): champs identified_* sur Media pour stocker l'identification"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6 : Tests backend — identification
|
|
|
|
**Files:**
|
|
- Créer: `backend/tests/test_identify.py`
|
|
|
|
**Étape 1 : Écrire le test avec mock PlantNet et ai-service**
|
|
|
|
```python
|
|
from unittest.mock import AsyncMock, patch
|
|
from fastapi.testclient import TestClient
|
|
from app.main import app
|
|
|
|
client = TestClient(app)
|
|
|
|
FAKE_IMAGE = b"\xff\xd8\xff\xe0" + b"\x00" * 100 # bytes JPEG minimal
|
|
|
|
|
|
def test_identify_returns_plantnet_results():
|
|
fake_results = [
|
|
{"species": "Solanum lycopersicum", "common_name": "Tomate",
|
|
"confidence": 0.95, "image_url": ""}
|
|
]
|
|
with patch("app.services.redis_cache.get", return_value=None), \
|
|
patch("app.services.redis_cache.set"), \
|
|
patch("app.services.plantnet.identify", new_callable=AsyncMock,
|
|
return_value=fake_results):
|
|
resp = client.post(
|
|
"/api/identify",
|
|
files={"file": ("test.jpg", FAKE_IMAGE, "image/jpeg")},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["source"] == "plantnet"
|
|
assert len(data["results"]) == 1
|
|
assert data["results"][0]["species"] == "Solanum lycopersicum"
|
|
|
|
|
|
def test_identify_falls_back_to_yolo_when_plantnet_fails():
|
|
fake_yolo = [{"species": "Tomato healthy", "common_name": "Tomate", "confidence": 0.80, "image_url": ""}]
|
|
with patch("app.services.redis_cache.get", return_value=None), \
|
|
patch("app.services.redis_cache.set"), \
|
|
patch("app.services.plantnet.identify", new_callable=AsyncMock, return_value=[]), \
|
|
patch("app.services.yolo_service.identify", new_callable=AsyncMock,
|
|
return_value=fake_yolo):
|
|
resp = client.post(
|
|
"/api/identify",
|
|
files={"file": ("test.jpg", FAKE_IMAGE, "image/jpeg")},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["source"] == "yolo"
|
|
|
|
|
|
def test_identify_uses_cache():
|
|
cached = [{"species": "Rosa canina", "common_name": "Églantier",
|
|
"confidence": 0.9, "image_url": ""}]
|
|
with patch("app.services.redis_cache.get", return_value=cached):
|
|
resp = client.post(
|
|
"/api/identify",
|
|
files={"file": ("test.jpg", FAKE_IMAGE, "image/jpeg")},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["source"] == "cache"
|
|
```
|
|
|
|
**Étape 2 : Lancer les tests**
|
|
|
|
```bash
|
|
cd backend && pytest tests/test_identify.py -v
|
|
```
|
|
Attendu : 3 tests PASSED
|
|
|
|
**Étape 3 : Lancer tous les tests**
|
|
|
|
```bash
|
|
cd backend && pytest tests/ -v
|
|
```
|
|
Attendu : tous les tests PASSED (27+ existants + 3 nouveaux)
|
|
|
|
**Étape 4 : Commit**
|
|
|
|
```bash
|
|
git add backend/tests/test_identify.py
|
|
git commit -m "test(backend): tests identification PlantNet, YOLO fallback et cache"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7 : Composant `PhotoGallery.vue`
|
|
|
|
**Files:**
|
|
- Créer: `frontend/src/components/PhotoGallery.vue`
|
|
|
|
**Étape 1 : Créer `frontend/src/components/PhotoGallery.vue`**
|
|
|
|
```vue
|
|
<template>
|
|
<div>
|
|
<div class="flex items-center justify-between mb-3">
|
|
<span class="text-text-muted text-sm">{{ medias.length }} photo(s)</span>
|
|
<label class="cursor-pointer bg-bg-soft text-text-muted hover:text-text px-3 py-1 rounded-lg text-xs border border-bg-hard transition-colors">
|
|
+ Photo
|
|
<input type="file" accept="image/*" capture="environment" class="hidden" @change="onUpload" />
|
|
</label>
|
|
</div>
|
|
|
|
<div v-if="loading" class="text-text-muted text-xs">Chargement...</div>
|
|
<div v-else-if="!medias.length" class="text-text-muted text-xs italic py-2">Aucune photo.</div>
|
|
|
|
<div class="grid grid-cols-3 gap-2">
|
|
<div v-for="m in medias" :key="m.id"
|
|
class="aspect-square rounded-lg overflow-hidden bg-bg-hard relative group cursor-pointer"
|
|
@click="lightbox = m">
|
|
<img :src="m.thumbnail_url || m.url" :alt="m.titre || ''"
|
|
class="w-full h-full object-cover" />
|
|
<div v-if="m.identified_common"
|
|
class="absolute bottom-0 left-0 right-0 bg-black/60 text-xs text-green px-1 py-0.5 truncate">
|
|
{{ m.identified_common }}
|
|
</div>
|
|
<button @click.stop="deleteMedia(m.id)"
|
|
class="absolute top-1 right-1 bg-black/60 text-red text-xs px-1 rounded opacity-0 group-hover:opacity-100 transition-opacity">
|
|
✕
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Lightbox -->
|
|
<div v-if="lightbox" class="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4"
|
|
@click.self="lightbox = null">
|
|
<div class="max-w-lg w-full">
|
|
<img :src="lightbox.url" class="w-full rounded-xl" />
|
|
<div v-if="lightbox.identified_species" class="text-center mt-2 text-text-muted text-sm">
|
|
<span class="text-green font-medium">{{ lightbox.identified_common }}</span>
|
|
— <em>{{ lightbox.identified_species }}</em>
|
|
({{ Math.round((lightbox.identified_confidence || 0) * 100) }}%)
|
|
</div>
|
|
<button class="mt-3 w-full text-text-muted text-sm hover:text-text" @click="lightbox = null">Fermer</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted } from 'vue'
|
|
import axios from 'axios'
|
|
|
|
const props = defineProps<{ entityType: string; entityId: number }>()
|
|
const medias = ref<any[]>([])
|
|
const loading = ref(false)
|
|
const lightbox = ref<any>(null)
|
|
|
|
async function fetchMedias() {
|
|
loading.value = true
|
|
const r = await axios.get('/api/media', { params: { entity_type: props.entityType, entity_id: props.entityId } })
|
|
medias.value = r.data
|
|
loading.value = false
|
|
}
|
|
|
|
async function onUpload(e: Event) {
|
|
const file = (e.target as HTMLInputElement).files?.[0]
|
|
if (!file) return
|
|
const fd = new FormData()
|
|
fd.append('file', file)
|
|
const { data: uploaded } = await axios.post('/api/upload', fd)
|
|
await axios.post('/api/media', {
|
|
entity_type: props.entityType,
|
|
entity_id: props.entityId,
|
|
url: uploaded.url,
|
|
thumbnail_url: uploaded.thumbnail_url,
|
|
})
|
|
await fetchMedias()
|
|
}
|
|
|
|
async function deleteMedia(id: number) {
|
|
if (!confirm('Supprimer cette photo ?')) return
|
|
await axios.delete(`/api/media/${id}`)
|
|
medias.value = medias.value.filter(m => m.id !== id)
|
|
}
|
|
|
|
onMounted(fetchMedias)
|
|
</script>
|
|
```
|
|
|
|
**Étape 2 : Commit**
|
|
|
|
```bash
|
|
git add frontend/src/components/PhotoGallery.vue
|
|
git commit -m "feat(frontend): composant PhotoGallery réutilisable avec lightbox"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8 : Modal d'identification `PhotoIdentifyModal.vue`
|
|
|
|
**Files:**
|
|
- Créer: `frontend/src/components/PhotoIdentifyModal.vue`
|
|
|
|
**Étape 1 : Créer `frontend/src/components/PhotoIdentifyModal.vue`**
|
|
|
|
```vue
|
|
<template>
|
|
<div class="fixed inset-0 bg-black/70 z-50 flex items-center justify-center p-4" @click.self="$emit('close')">
|
|
<div class="bg-bg-hard rounded-xl p-6 w-full max-w-md border border-bg-soft">
|
|
<h2 class="text-text font-bold text-lg mb-4">Identifier une plante</h2>
|
|
|
|
<!-- Upload zone -->
|
|
<div v-if="!previewUrl" class="border-2 border-dashed border-bg-soft rounded-xl p-8 text-center mb-4">
|
|
<p class="text-text-muted text-sm mb-3">Glisser une photo ou</p>
|
|
<label class="cursor-pointer bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold">
|
|
Choisir / Photographier
|
|
<input type="file" accept="image/*" capture="environment" class="hidden" @change="onFileSelect" />
|
|
</label>
|
|
</div>
|
|
|
|
<!-- Preview + résultats -->
|
|
<div v-else>
|
|
<img :src="previewUrl" class="w-full rounded-lg mb-4 max-h-48 object-cover" />
|
|
|
|
<div v-if="loading" class="text-text-muted text-sm text-center py-4">
|
|
Identification en cours...
|
|
</div>
|
|
|
|
<div v-else-if="results.length">
|
|
<p class="text-text-muted text-xs mb-2">
|
|
Source : <span class="text-yellow font-mono">{{ source }}</span>
|
|
</p>
|
|
<div v-for="(r, i) in results" :key="i"
|
|
class="mb-2 p-3 rounded-lg border cursor-pointer transition-colors"
|
|
:class="selected === i ? 'border-green bg-green/10' : 'border-bg-soft bg-bg hover:border-green/50'"
|
|
@click="selected = i">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<div class="text-text font-medium text-sm">{{ r.common_name || r.species }}</div>
|
|
<div class="text-text-muted text-xs italic">{{ r.species }}</div>
|
|
</div>
|
|
<div class="text-right">
|
|
<div class="text-green text-sm font-bold">{{ Math.round(r.confidence * 100) }}%</div>
|
|
</div>
|
|
</div>
|
|
<div class="mt-1 h-1 bg-bg-soft rounded-full overflow-hidden">
|
|
<div class="h-full bg-green rounded-full" :style="{ width: `${r.confidence * 100}%` }" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="mt-4 flex flex-col gap-2">
|
|
<div class="flex gap-2">
|
|
<select v-model="linkPlantId"
|
|
class="flex-1 bg-bg border border-bg-soft rounded-lg px-3 py-2 text-text text-sm outline-none focus:border-green">
|
|
<option :value="null">— Associer à une plante existante</option>
|
|
<option v-for="p in plants" :key="p.id" :value="p.id">{{ p.nom_commun }}</option>
|
|
</select>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<button @click="saveAndLink" :disabled="selected === null"
|
|
class="flex-1 bg-green text-bg py-2 rounded-lg text-sm font-semibold hover:opacity-90 disabled:opacity-40">
|
|
Enregistrer
|
|
</button>
|
|
<button @click="$emit('close')" class="px-4 py-2 text-text-muted hover:text-text text-sm">
|
|
Annuler
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else-if="!loading" class="text-text-muted text-sm text-center py-4">
|
|
Aucune plante identifiée. Essayez avec une photo plus nette.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted } from 'vue'
|
|
import axios from 'axios'
|
|
|
|
const emit = defineEmits(['close', 'identified'])
|
|
|
|
const previewUrl = ref<string | null>(null)
|
|
const imageFile = ref<File | null>(null)
|
|
const loading = ref(false)
|
|
const results = ref<any[]>([])
|
|
const source = ref('')
|
|
const selected = ref<number | null>(null)
|
|
const plants = ref<any[]>([])
|
|
const linkPlantId = ref<number | null>(null)
|
|
|
|
onMounted(async () => {
|
|
const { data } = await axios.get('/api/plants')
|
|
plants.value = data
|
|
})
|
|
|
|
async function onFileSelect(e: Event) {
|
|
const file = (e.target as HTMLInputElement).files?.[0]
|
|
if (!file) return
|
|
imageFile.value = file
|
|
previewUrl.value = URL.createObjectURL(file)
|
|
await identify()
|
|
}
|
|
|
|
async function identify() {
|
|
loading.value = true
|
|
results.value = []
|
|
selected.value = null
|
|
try {
|
|
const fd = new FormData()
|
|
fd.append('file', imageFile.value!)
|
|
const { data } = await axios.post('/api/identify', fd)
|
|
results.value = data.results
|
|
source.value = data.source
|
|
if (results.value.length) selected.value = 0
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function saveAndLink() {
|
|
if (!imageFile.value || selected.value === null) return
|
|
const r = results.value[selected.value]
|
|
|
|
// Upload la photo
|
|
const fd = new FormData()
|
|
fd.append('file', imageFile.value)
|
|
const { data: uploaded } = await axios.post('/api/upload', fd)
|
|
|
|
// Sauvegarder dans Media lié à la plante si sélectionnée
|
|
if (linkPlantId.value) {
|
|
await axios.post('/api/media', {
|
|
entity_type: 'plante',
|
|
entity_id: linkPlantId.value,
|
|
url: uploaded.url,
|
|
thumbnail_url: uploaded.thumbnail_url,
|
|
identified_species: r.species,
|
|
identified_common: r.common_name,
|
|
identified_confidence: r.confidence,
|
|
identified_source: source.value,
|
|
})
|
|
}
|
|
|
|
emit('identified', { ...r, imageUrl: uploaded.url, plantId: linkPlantId.value })
|
|
emit('close')
|
|
}
|
|
</script>
|
|
```
|
|
|
|
**Étape 2 : Commit**
|
|
|
|
```bash
|
|
git add frontend/src/components/PhotoIdentifyModal.vue
|
|
git commit -m "feat(frontend): modal PhotoIdentifyModal avec upload + identification"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9 : Vue `BibliothequeView.vue` + route + navigation
|
|
|
|
**Files:**
|
|
- Créer: `frontend/src/views/BibliothequeView.vue`
|
|
- Modifier: `frontend/src/router/index.ts`
|
|
- Modifier: `frontend/src/components/AppDrawer.vue`
|
|
|
|
**Étape 1 : Créer `frontend/src/views/BibliothequeView.vue`**
|
|
|
|
```vue
|
|
<template>
|
|
<div class="p-4 max-w-4xl mx-auto">
|
|
<div class="flex items-center justify-between mb-6">
|
|
<h1 class="text-2xl font-bold text-green">📷 Bibliothèque</h1>
|
|
<button @click="showIdentify = true"
|
|
class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90">
|
|
Identifier une plante
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Filtres -->
|
|
<div class="flex gap-2 mb-4 flex-wrap">
|
|
<button v-for="f in filters" :key="f.val"
|
|
@click="activeFilter = f.val"
|
|
:class="['px-3 py-1 rounded-full text-xs font-medium transition-colors',
|
|
activeFilter === f.val ? 'bg-green text-bg' : 'bg-bg-soft text-text-muted hover:text-text']">
|
|
{{ f.label }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Grille -->
|
|
<div v-if="loading" class="text-text-muted text-sm">Chargement...</div>
|
|
<div v-else-if="!filtered.length" class="text-text-muted text-sm py-4">Aucune photo.</div>
|
|
<div v-else class="grid grid-cols-3 md:grid-cols-4 gap-2">
|
|
<div v-for="m in filtered" :key="m.id"
|
|
class="aspect-square rounded-lg overflow-hidden bg-bg-hard relative group cursor-pointer"
|
|
@click="lightbox = m">
|
|
<img :src="m.thumbnail_url || m.url" :alt="m.titre || ''"
|
|
class="w-full h-full object-cover" />
|
|
<div v-if="m.identified_common"
|
|
class="absolute bottom-0 left-0 right-0 bg-black/70 text-xs text-green px-1 py-0.5 truncate">
|
|
{{ m.identified_common }}
|
|
</div>
|
|
<div class="absolute top-1 left-1 bg-black/60 text-text-muted text-xs px-1 rounded">
|
|
{{ labelFor(m.entity_type) }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Lightbox -->
|
|
<div v-if="lightbox" class="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4"
|
|
@click.self="lightbox = null">
|
|
<div class="max-w-lg w-full">
|
|
<img :src="lightbox.url" class="w-full rounded-xl" />
|
|
<div v-if="lightbox.identified_species" class="text-center mt-3 text-text-muted text-sm">
|
|
<div class="text-green font-semibold text-base">{{ lightbox.identified_common }}</div>
|
|
<div class="italic">{{ lightbox.identified_species }}</div>
|
|
<div class="text-xs mt-1">
|
|
Confiance : {{ Math.round((lightbox.identified_confidence || 0) * 100) }}%
|
|
— via {{ lightbox.identified_source }}
|
|
</div>
|
|
</div>
|
|
<button class="mt-4 w-full text-text-muted hover:text-text text-sm" @click="lightbox = null">Fermer</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal identification -->
|
|
<PhotoIdentifyModal v-if="showIdentify" @close="showIdentify = false; fetchAll()" />
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onMounted, ref } from 'vue'
|
|
import axios from 'axios'
|
|
import PhotoIdentifyModal from '@/components/PhotoIdentifyModal.vue'
|
|
|
|
const medias = ref<any[]>([])
|
|
const loading = ref(false)
|
|
const lightbox = ref<any>(null)
|
|
const showIdentify = ref(false)
|
|
const activeFilter = ref('')
|
|
|
|
const filters = [
|
|
{ val: '', label: 'Toutes' },
|
|
{ val: 'plante', label: '🌱 Plantes' },
|
|
{ val: 'jardin', label: '🏡 Jardins' },
|
|
{ val: 'plantation', label: '🥕 Plantations' },
|
|
{ val: 'outil', label: '🔧 Outils' },
|
|
]
|
|
|
|
const filtered = computed(() =>
|
|
activeFilter.value ? medias.value.filter(m => m.entity_type === activeFilter.value) : medias.value
|
|
)
|
|
|
|
function labelFor(type: string) {
|
|
return { plante: '🌱', jardin: '🏡', plantation: '🥕', outil: '🔧' }[type] || '📷'
|
|
}
|
|
|
|
async function fetchAll() {
|
|
loading.value = true
|
|
// Charge toutes les photos en faisant plusieurs requêtes par type
|
|
const types = ['plante', 'jardin', 'plantation', 'outil']
|
|
const all: any[] = []
|
|
// On utilise l'endpoint sans filtre entity_id en récupérant par type
|
|
// Le backend actuel filtre par entity_type + entity_id,
|
|
// donc on ajoute un endpoint GET /api/media/all ou on adapte
|
|
// Pour l'instant on fait un appel direct à la DB via /api/media sans entity_id
|
|
// → NÉCESSITE d'adapter le router media (voir étape 3)
|
|
try {
|
|
const { data } = await axios.get('/api/media/all')
|
|
medias.value = data
|
|
} catch {
|
|
medias.value = []
|
|
}
|
|
loading.value = false
|
|
}
|
|
|
|
onMounted(fetchAll)
|
|
</script>
|
|
```
|
|
|
|
**Étape 2 : Ajouter l'endpoint `GET /api/media/all` dans `backend/app/routers/media.py`**
|
|
|
|
Ajouter avant le endpoint existant `GET /api/media` :
|
|
|
|
```python
|
|
@router.get("/media/all", response_model=List[Media])
|
|
def list_all_media(
|
|
entity_type: Optional[str] = Query(default=None),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
"""Retourne tous les médias, optionnellement filtrés par entity_type."""
|
|
q = select(Media)
|
|
if entity_type:
|
|
q = q.where(Media.entity_type == entity_type)
|
|
return session.exec(q.order_by(Media.created_at.desc())).all()
|
|
```
|
|
|
|
Ajouter `Optional` à l'import :
|
|
```python
|
|
from typing import List, Optional
|
|
```
|
|
|
|
**Étape 3 : Ajouter la route dans `frontend/src/router/index.ts`**
|
|
|
|
Ajouter dans le tableau `routes` :
|
|
```typescript
|
|
{ path: '/bibliotheque', component: () => import('@/views/BibliothequeView.vue') },
|
|
```
|
|
|
|
**Étape 4 : Ajouter le lien dans `frontend/src/components/AppDrawer.vue`**
|
|
|
|
Dans le tableau `links`, ajouter après `{ to: '/plantes', label: 'Plantes' }` :
|
|
```typescript
|
|
{ to: '/bibliotheque', label: 'Bibliothèque' },
|
|
```
|
|
|
|
**Étape 5 : Rebuild et tester**
|
|
|
|
```bash
|
|
docker compose up --build -d
|
|
# Ouvrir http://127.0.0.1:8061/bibliotheque
|
|
```
|
|
|
|
**Étape 6 : Commit**
|
|
|
|
```bash
|
|
git add frontend/src/views/BibliothequeView.vue \
|
|
frontend/src/router/index.ts \
|
|
frontend/src/components/AppDrawer.vue \
|
|
backend/app/routers/media.py
|
|
git commit -m "feat: vue BibliothequeView + route /bibliotheque + nav"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10 : Intégration `PhotoGallery` dans les fiches existantes (optionnel)
|
|
|
|
Cette tâche est optionnelle pour le MVP. Elle consiste à ajouter `<PhotoGallery>` dans les fiches plantes, jardins, etc.
|
|
|
|
**Exemple dans `PlantesView.vue` :** ajouter après la liste d'infos de chaque plante :
|
|
```vue
|
|
<PhotoGallery entity-type="plante" :entity-id="p.id" />
|
|
```
|
|
|
|
**Commit quand fait :**
|
|
```bash
|
|
git commit -m "feat(frontend): galerie photos dans fiches plantes"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11 : Build final et vérification
|
|
|
|
**Étape 1 : Lancer tous les tests backend**
|
|
|
|
```bash
|
|
cd backend && pytest tests/ -v
|
|
```
|
|
Attendu : tous PASSED
|
|
|
|
**Étape 2 : Build complet Docker**
|
|
|
|
```bash
|
|
docker compose down
|
|
docker compose up --build -d
|
|
docker compose logs --tail=30
|
|
```
|
|
Attendu : 4 services démarrés (backend, ai-service, redis, frontend)
|
|
|
|
**Étape 3 : Test fonctionnel**
|
|
|
|
1. Aller sur `http://127.0.0.1:8061/bibliotheque`
|
|
2. Cliquer "Identifier une plante"
|
|
3. Uploader une photo de tomate ou autre plante
|
|
4. Vérifier les résultats PlantNet (ou YOLO si offline)
|
|
5. Associer à une plante et vérifier dans la galerie
|
|
|
|
**Étape 4 : Commit final**
|
|
|
|
```bash
|
|
git commit -m "feat: bibliothèque photo + identification PlantNet/YOLO + cache Redis"
|
|
```
|