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
- Aller sur
http://127.0.0.1:8061/bibliotheque - Cliquer "Identifier une plante"
- Uploader une photo de tomate ou autre plante
- Vérifier les résultats PlantNet (ou YOLO si offline)
- 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"