Pipeline de Traitement d'Images à Grande Échelle : Tri Automatique, Génération IA et Benchmarking
Le Problème : Traiter des Milliers d’Images avec un Niveau de Qualité Constant
Quand on gère des volumes importants d’images produit, trois problèmes reviennent systématiquement : le tri (quelle image montre quoi), la génération (créer les visuels manquants), et le contrôle qualité (s’assurer que le résultat est publiable). Faire cela manuellement à l’échelle de centaines ou milliers de produits est un gouffre de temps et d’argent.
Nous avons construit un pipeline modulaire qui automatise les trois étapes. Cet article détaille l’architecture technique, les choix de modèles, et les métriques de qualité qui nous permettent de maintenir un niveau constant sans intervention humaine.
Architecture Générale : Trois Modules Indépendants
Le pipeline se divise en trois modules autonomes, chacun dans son propre répertoire avec ses dépendances :
| Module | Rôle | Technologies clés |
|---|---|---|
bench/ | Benchmarking multi-modèles + scoring qualité | Runware API, Mistral VLM, SQLite |
experiments/ | R&D : appariement d’images, classification VLM | DINOv2, Qwen3-VL, Llama-4 |
resources/ | Pipeline de production complet | CLI, génération, post-processing |
Chaque module peut fonctionner seul. Le module bench compare les modèles de génération. Le module experiments explore de nouvelles approches de tri. Le module resources orchestre la production.
Module 1 : Tri Automatique par Embeddings DINOv2
Le Problème du Tri
Un lot d’images produit contient typiquement des photos face, dos, portées, détails, et ambiance --- dans le désordre, sans métadonnées fiables. Le tri manuel prend entre 30 secondes et 2 minutes par produit. A 500 produits, c’est 4 à 16 heures de travail purement mécanique.
DINOv2 CLS Embeddings
Notre approche repose sur les embeddings DINOv2, extraits dans le module d’appariement d’images. Chaque image est passée dans facebook/dinov2-base, et on récupère le CLS token (768 dimensions) :
def compute_embeddings(
image_dir: Path,
model_name: str = "facebook/dinov2-base",
use_cache: bool = True,
on_progress: Optional[callable] = None,
) -> dict[str, np.ndarray]:
device = "cpu" # Force CPU — GPU peut être occupé par d'autres process
processor = AutoImageProcessor.from_pretrained(model_name)
model = AutoModel.from_pretrained(model_name).to(device).eval()
with torch.no_grad():
for fname in image_files:
img = Image.open(image_dir / fname).convert("RGB")
inputs = processor(images=img, return_tensors="pt").to(device)
outputs = model(**inputs)
cls_embedding = outputs.last_hidden_state[:, 0, :].cpu().numpy().flatten()
# L2-normalize pour similarité cosinus
norm = np.linalg.norm(cls_embedding)
if norm > 0:
cls_embedding = cls_embedding / norm
embeddings[fname] = cls_embedding.astype(np.float32) Un point de design important : on force le CPU. Pourquoi ? En production, le GPU est souvent occupé par le modèle de génération d’images ou par un VLM local (Ollama). L’embedding DINOv2 est assez léger pour tourner en CPU sans pénalité significative.
Les embeddings sont mis en cache dans un fichier .embeddings_cache.npz pour éviter de recalculer à chaque exécution. Le cache est invalidé uniquement si la liste des fichiers change.
Appariement par Similarité Cosinus
Une fois les embeddings calculés, on construit une matrice de similarité cosinus dense (puisque les vecteurs sont L2-normalisés, c’est un simple produit matriciel) :
vectors = np.array([embeddings[name] for name in names])
matrix = vectors @ vectors.T # Cosine sim = dot product (vecteurs normalisés)
np.fill_diagonal(matrix, 0.0) Le module d’appariement supporte quatre stratégies, chacune adaptée à un cas d’usage :
Stratégie “consecutive” (par défaut) : exploite le fait que les photos face et dos d’un même produit sont souvent prises à la suite. Passe 1 : apparie les images avec des numéros de fichiers consécutifs si leur similarité dépasse consecutive_threshold=0.55. Passe 2 : greedy matching classique sur les images restantes avec un seuil plus élevé (fallback_threshold=0.92).
async def find_pairs(
embeddings, threshold=0.75,
consecutive_threshold=0.55,
fallback_threshold=0.92,
strategy="consecutive",
vlm_model=None,
no_vlm_verify=False,
) -> tuple[list[tuple[str, str, float]], list[str]]: Stratégie “mnn” (Mutual Nearest Neighbors) : plus robuste quand les fichiers ne sont pas ordonnés. Chaque image doit être le plus proche voisin de son match ET réciproquement.
Stratégie “trio” et “quad” : utilisent un VLM (Qwen3-VL) pour regrouper les images par lots de 3 ou 4 en analysant visuellement le contenu. Plus lent mais plus précis pour les cas ambigus.
Passe 1.5 : Vérification VLM
Entre le matching par embedding et la production finale, une passe optionnelle fait vérifier les paires par un Vision-Language Model. Le VLM reçoit les deux images d’une paire et confirme (ou invalide) qu’il s’agit bien du même produit vu sous deux angles.
Ce n’est pas un luxe. A consecutive_threshold=0.55, le rappel est bon mais la précision baisse. La vérification VLM rattrape les faux positifs sans pénaliser le rappel. Le paramètre vlm_min_sim=0.10 évite d’envoyer au VLM des paires évidemment mauvaises.
Module 2 : Génération d’Images Multi-Modèles
Catalogue de Modèles de Génération
Le pipeline intègre un catalogue complet de modèles de génération d’images, chacun profilé par appels API réels pour documenter ses capacités et limitations :
RUNWARE_MODELS = [
{"id": "civitai:257749@290640", "name": "Juggernaut XL v9",
"cost": 0.0007, "img2img": True, "prompt_type": "descriptive"},
{"id": "runware:101@1", "name": "FLUX.1 Dev",
"cost": 0.0030, "img2img": True, "prompt_type": "descriptive"},
{"id": "runware:108@1", "name": "Qwen-Image",
"cost": 0.0058, "img2img": False, "prompt_type": "descriptive"},
{"id": "runware:97@1", "name": "HiDream-I1 Full",
"cost": 0.0090, "img2img": True, "prompt_type": "descriptive"},
{"id": "runware:106@1", "name": "FLUX.1 Kontext [dev]",
"cost": 0.0105, "img2img": True, "prompt_type": "instruction",
"use_referenceImages": True},
] Chaque modèle a ses spécificités documentées dans le code :
img2img: supporte-t-il la génération à partir d’une image source ?prompt_type: “descriptive” (décrit le résultat souhaité) ou “instruction” (FLUX Kontext, qui prend des instructions de transformation)skip_params: paramètres API non supportés par ce modèle spécifiqueuse_referenceImages: envoie l’image comme référence plutôt que seed (Kontext, Grok)dims: dimensions fixes imposées ou libres
Cette granularité n’est pas théorique. Chaque modèle a été profilé par appels API réels. Par exemple, FLUX.1 Kontext ne supporte ni width, ni height, ni seedImage, ni steps, ni CFGScale --- il faut envoyer les images via referenceImages[] et les instructions via un prompt spécifique.
Le Client WebSocket
Le client utilise l’API WebSocket pour la génération, et l’API REST pour le post-processing. La structure de requête est standardisée :
@dataclass
class RunConfig:
exp_id: str
model_id: str
image_path: Path
image_type: str # FRONT_NEUTRAL / BACK_NEUTRAL / WORN_OR_STAGED
prompt: str
negative_prompt: str = "blurry, low quality, deformed, ugly, bad anatomy, watermark, text"
width: int = 768
height: int = 1024
steps: int = 25
cfg_scale: float = 7.0
seed: int = 42
strength: float = 0.75
remove_bg: bool = False
upscale_factor: int = 1
validate: bool = True
vlm_model: str = "mistral-small-latest" Le seed fixé à 42 par défaut garantit la reproductibilité des benchmarks. En production, on le randomise.
Module 3 : Scoring Qualité par VLM
Validation Automatique Multi-Critères
Chaque image générée est évaluée par un VLM (Mistral ou Qwen3) sur des critères de qualité précis :
@dataclass
class ValidationScores:
product_fidelity: float = 0.0 # 0-10 : le produit correspond à la source ?
publication_readiness: float = 0.0 # 0-10 : publiable directement ?
artifact_free: float = 0.0 # 0-10 : pas de déformations/artefacts ?
model_realism: Optional[float] = None # 0-10 : la personne a l'air réelle ?
garment_drape: Optional[float] = None # 0-10 : le drapé est naturel ?
@property
def composite_score(self) -> float:
"""Score composite pondéré — publication_readiness est la north star."""
scores = [
self.product_fidelity * 0.30,
self.publication_readiness * 0.40,
self.artifact_free * 0.30,
] Le composite_score pondère les critères avec la publication_readiness comme métrique dominante (40%). C’est un choix opérationnel : un résultat peut être techniquement imparfait mais publiable, ou techniquement parfait mais inutilisable (mauvais cadrage, fond inapproprié).
Les scores model_realism et garment_drape ne s’appliquent qu’aux images de type “porté” (WORN_OR_STAGED) et sont exclus du composite pour les images plates.
Multi-Backend VLM
Le validateur supporte trois backends interchangeables :
MISTRAL_API_URL = "https://api.mistral.ai/v1/chat/completions"
DEEPINFRA_API_URL = "https://api.deepinfra.com/v1/openai/chat/completions"
OLLAMA_DEFAULT_HOST = "http://192.168.1.36:11434"
COST_PER_1K_TOKENS = {
"mistral-small-latest": 0.0002,
"mistral-large-latest": 0.003,
}
DEEPINFRA_MODELS = {
"Qwen/Qwen3-VL-30B-A3B-Instruct": 0.0003,
"Qwen/Qwen3-VL-235B-A22B-Instruct": 0.0008,
"Qwen/Qwen2.5-VL-72B-Instruct": 0.0008,
"meta-llama/Llama-4-Maverick-17B-128E-Instruct": 0.0003,
} En benchmark, on utilise Mistral Small (0.0002$/1K tokens) pour le volume, et Mistral Large pour la calibration. En production, un modèle Ollama local élimine les coûts API récurrents. Le coût estimé par appel de validation est d’environ 700 tokens (500 input + 200 output), soit environ 0.00014$ par image avec Mistral Small.
Le BenchRunner : Orchestration avec Budget Tracking
Le BenchRunner orchestre le pipeline complet pour chaque image :
class BenchRunner:
def __init__(self, exp_id, run_label="", dry_run=False,
budget_usd=999.0, params=None):
self.budget_usd = budget_usd
self._spent = 0.0 Le budget tracking est crucial. Chaque appel API (génération + validation) est comptabilisé. Le runner s’arrête automatiquement si le budget est dépassé. En mode dry_run=True, toute la logique s’exécute sans appels API réels, pour valider la configuration.
Stockage SQLite
Les résultats sont persistés dans une base SQLite via un module de stockage dédié :
CREATE TABLE IF NOT EXISTS runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
exp_id TEXT NOT NULL,
run_label TEXT,
started_at TEXT,
finished_at TEXT,
dry_run INTEGER DEFAULT 0,
params_json TEXT,
budget_usd REAL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS model_ordering (
model_name TEXT PRIMARY KEY,
display_order INTEGER NOT NULL DEFAULT 999,
rating INTEGER DEFAULT NULL
); Le choix de SQLite plutôt qu’un CSV ou JSON est délibéré : les requêtes d’agrégation (score moyen par modèle, coût total par expérience, meilleur modèle par type d’image) sont triviales en SQL et impossibles à faire proprement en fichier plat. Le mode WAL (PRAGMA journal_mode=WAL) permet des lectures concurrentes pendant que le runner écrit.
Post-Processing : De l’Image Brute au Produit Fini
Le module de post-processing contient les transformations finales appliquées aux images générées.
Égalisation de Panneaux
def equalize_panels(image_path: Path, n_panels: int = 3, line_width: int = 2) -> Path:
"""Découpe l'image en n_panels égaux et insère des lignes noires."""
img = Image.open(image_path).convert("RGB")
panel_w = src_w // n_panels
canvas = Image.new("RGB", (out_w, src_h), color=(0, 0, 0))
for i in range(n_panels):
left = i * panel_w
panel = img.crop((left, 0, left + panel_w, src_h))
paste_x = i * panel_w + i * line_width
canvas.paste(panel, (paste_x, 0)) Découpe Face/Dos
def crop_panels(flatlay_path, worn_path, output_dir, product_key, margin=6):
"""Crop flat-lay en panneaux face/dos + portrait porté."""
flatlay = Image.open(flatlay_path).convert("RGB")
half_w = w // 2
aspect = h / half_w
front_w = half_w - margin
front_h = int(front_w * aspect)
v_trim = (h - front_h) // 2
front = flatlay.crop((0, v_trim, front_w, v_trim + front_h))
back = flatlay.crop((half_w + margin, v_trim, w, v_trim + front_h)) Le paramètre margin=6 pixels est empirique : il correspond à la zone de séparation floue entre les panneaux face et dos dans les images générées. Trop petit et on a des artefacts de bord ; trop grand et on perd du contenu.
Recadrage Intelligent sur le Contenu
def crop_to_content_portrait(image_path, output_path,
target_width=1200, target_height=1600,
padding_pct=0.05):
"""Recadre autour du sujet et centre sur un canvas portrait."""
img = Image.open(image_path).convert("RGBA")
bbox = img.getbbox() # Bounding box du contenu non-transparent
content = img.crop(bbox)
scale = min(avail_w / cw, avail_h / ch)
content = content.resize((new_w, new_h), Image.LANCZOS) Cette fonction utilise le canal alpha pour détecter automatiquement le sujet (après suppression de fond), puis le recentre sur un canvas de dimensions fixes. Le ratio 1200x1600 (3:4) est le standard pour les fiches produit.
Assemblage Multi-Panneaux
def concat_flatlay_worn(flatlay_path, worn_path, output_path, line_width=2):
"""Concatène un flat-lay 2 panneaux avec une image portée en composite 3 panneaux."""
target_h = flatlay.height
panel_w = flatlay.width // 2
total_w = panel_w * 2 + worn.width + line_width * 2
canvas = Image.new("RGB", (total_w, target_h), color=(0, 0, 0)) La hauteur du flat-lay sert de référence. L’image portée est redimensionnée proportionnellement. C’est un choix pragmatique : le flat-lay a la meilleure résolution car il est généré directement ; l’image portée est souvent de moindre qualité.
Métriques de Coût : Ce Que Chaque Image Coûte Réellement
L’un des aspects les plus utiles du pipeline est le tracking de coût granulaire. Voici les coûts réels par opération :
| Opération | Coût unitaire | Volume type | Coût total |
|---|---|---|---|
| Embedding DINOv2 | 0$ (local) | 500 images | 0$ |
| Génération FLUX.1 Dev | 0.003$ | 500 images | 1.50$ |
| Génération Juggernaut XL | 0.0007$ | 500 images | 0.35$ |
| Validation Mistral Small | 0.00014$ | 500 images | 0.07$ |
| Suppression fond | ~0.001$ | 500 images | 0.50$ |
| Total (FLUX.1 Dev + validation) | 500 produits | ~2.07$ |
A 500 produits, le coût total est inférieur à 3 dollars. Comparé au coût humain du même travail (16 heures x 25-50 euros/h), le ROI est immédiat.
Lecons Apprises
Ce Qui Marche Bien
DINOv2 pour le tri : les embeddings CLS sont remarquablement stables. Deux photos du même produit sous des angles différents ont une similarité cosinus entre 0.6 et 0.85, alors que deux produits différents tombent rarement au-dessus de 0.4. La séparation est nette.
Le scoring VLM composite : la pondération 30/40/30 (fidélité/publiabilité/artefacts) correspond bien au jugement humain. Après calibration sur 200 images annotées manuellement, la corrélation entre le score composite et le jugement humain dépasse 0.85.
SQLite pour le stockage : avec WAL mode, on peut lancer un benchmark de 12 heures, le requêter en temps réel depuis un autre process, et ne jamais avoir de verrou. Simple et efficace.
Ce Qui Pose Problème
Les modèles à prompt instructionnel : FLUX.1 Kontext est puissant mais imprévisible. Le même prompt produit des résultats très variables selon la complexité de l’image source. Les modèles descriptifs (FLUX.1 Dev, HiDream) sont plus stables.
Le cold-start des modèles API : certains modèles (notamment CivitAI hébergés) timeout régulièrement à cause du cold-start. En production, on préchauffe les modèles avec une image dummy avant le batch réel.
La calibration VLM : un VLM qui donne 7/10 à tout est inutile. Il faut calibrer régulièrement le prompt de scoring contre des annotations humaines. Le prompt est la partie la plus fragile du pipeline.
Améliorations Futures
Le pipeline actuel est séquentiel par produit. La parallélisation est triviale (chaque produit est indépendant) mais n’est pas encore implémentée dans le runner. Un pool asyncio de 10-20 workers diviserait le temps de traitement par un facteur similaire.
L’appariement VLM (stratégies “trio” et “quad”) est prometteur mais coûteux. Un système hybride --- DINOv2 pour le pre-filter, VLM uniquement sur les cas ambigus --- donnerait le meilleur des deux mondes.
Un projet similaire ? Contactez Loïck Briot : contact@brio-novia.eu