Files
jardin/docs/plans/2026-02-22-bibliotheque-photo-plan.md
2026-02-22 15:05:40 +01:00

32 KiB

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

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

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 :

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

docker compose build ai-service

Attendu : build réussi (peut prendre 2-3 min, télécharge ultralytics)

Étape 6 : Commit

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

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)

touch backend/app/services/__init__.py

Étape 4 : Commit

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

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

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

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

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 :

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(...) :

app.include_router(identify.router, prefix="/api")

Étape 3 : Tester manuellement (backend local)

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

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

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 :

# 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

docker compose up --build -d backend
docker compose logs backend --tail=20

Attendu : démarrage sans erreur, migrations appliquées.

Étape 4 : Commit

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

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

cd backend && pytest tests/test_identify.py -v

Attendu : 3 tests PASSED

Étape 3 : Lancer tous les tests

cd backend && pytest tests/ -v

Attendu : tous les tests PASSED (27+ existants + 3 nouveaux)

Étape 4 : Commit

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

<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

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

<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

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

<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 :

@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 :

from typing import List, Optional

Étape 3 : Ajouter la route dans frontend/src/router/index.ts

Ajouter dans le tableau routes :

{ 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' } :

{ to: '/bibliotheque', label: 'Bibliothèque' },

Étape 5 : Rebuild et tester

docker compose up --build -d
# Ouvrir http://127.0.0.1:8061/bibliotheque

Étape 6 : Commit

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 :

<PhotoGallery entity-type="plante" :entity-id="p.id" />

Commit quand fait :

git commit -m "feat(frontend): galerie photos dans fiches plantes"

Task 11 : Build final et vérification

Étape 1 : Lancer tous les tests backend

cd backend && pytest tests/ -v

Attendu : tous PASSED

Étape 2 : Build complet Docker

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

git commit -m "feat: bibliothèque photo + identification PlantNet/YOLO + cache Redis"