Pipeline de Traitement d'Images à Grande Échelle : Tri Automatique, Génération IA et Benchmarking

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 :

ModuleRôleTechnologies clés
bench/Benchmarking multi-modèles + scoring qualitéRunware API, Mistral VLM, SQLite
experiments/R&D : appariement d’images, classification VLMDINOv2, Qwen3-VL, Llama-4
resources/Pipeline de production completCLI, 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écifique
  • use_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érationCoût unitaireVolume typeCoût total
Embedding DINOv20$ (local)500 images0$
Génération FLUX.1 Dev0.003$500 images1.50$
Génération Juggernaut XL0.0007$500 images0.35$
Validation Mistral Small0.00014$500 images0.07$
Suppression fond~0.001$500 images0.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