# 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 ``` **É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 ``` **É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 ``` **É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 `` dans les fiches plantes, jardins, etc. **Exemple dans `PlantesView.vue` :** ajouter après la liste d'infos de chaque plante : ```vue ``` **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" ```