Scoring VLM : Évaluer la Qualité de Bulles BD Automatiquement

Scoring VLM : Évaluer la Qualité de Bulles BD Automatiquement

Scoring VLM : Évaluer la Qualité de Bulles BD Automatiquement

Comment savoir si un algorithme de placement de bulles BD produit des résultats utilisables, sans regarder chaque image manuellement ? C’est la question que nous avons résolue avec un pipeline autonome qui génère des planches, les annote avec SAM3, et les fait évaluer par un VLM — le tout sans intervention humaine.

En BD numérique, le placement automatique de bulles est un problème non trivial : selon une étude de 2023 sur la génération de contenu BD, 67% des placements automatiques nécessitent une correction manuelle. Notre objectif était de descendre sous 20% en combinant génération synthétique de données et validation VLM.

L’architecture du pipeline autonome

Le pipeline bd_dataset_agent est organisé en 8 modules Python qui s’enchaînent automatiquement :

src/
├── config.py           # Chargement YAML (classes, diversity_matrix, budget)
├── generator.py        # Génération d'images FLUX.2 Klein (GPU local)
├── annotator.py        # Annotation SAM3 text-prompted (8 classes BD)
├── vlm_client.py       # Client VLM Qwen3-VL pour validation
├── dataset_manager.py  # Export YOLO, split train/val, statistiques
├── trainer.py          # Wrapper ultralytics YOLO
├── evaluator.py        # Parse résultats, mAP, per-class AP
├── diagnostician.py    # Diagnostic autonome et actions correctives
└── orchestrator.py     # Boucle principale du pipeline

La boucle principale de l’orchestrateur est simple à lire :

class Orchestrator:
    """Autonomous pipeline: generate → annotate → train → evaluate → diagnose → loop."""

    def run(self) -> Dict[str, Any]:
        logger.info("Budget: %d iterations max, mAP target: %.2f",
                    self.budget.max_iterations, self.budget.min_map50)

Le budget est défini en YAML : nombre d’itérations maximum et seuil de mAP cible. Le pipeline s’arrête quand il atteint l’objectif — ou quand il manque de budget.

Stage 1 : Generation d’images avec FLUX.2 Klein

Le generateur src/generator.py utilise deux backends : un GPU local avec FLUX.2 Klein (4B ou 9B quantise 4-bit) et l’API Runware en fallback. Le mode auto detecte la presence d’un GPU CUDA et bascule automatiquement :

LOCAL_MODELS = {
    "4b": "black-forest-labs/FLUX.2-klein-4B",
    "9b-4bit": "black-forest-labs/FLUX.2-klein-9B",
}

def _resolve_backend(self) -> str:
    if _gpu_available():
        return "local"
    return "runware"

La configuration par defaut genere des images 1024x1024 en 4 steps avec un guidance_scale de 1.0 — les parametres optimaux pour FLUX.2 Klein qui produit des resultats coherents meme avec un nombre de steps tres bas.

La premiere iteration utilise la matrice de diversite complete ; les iterations suivantes sont ciblees par le diagnostician. Si la classe mouth est sous-performante, le generateur produit specifiquement des gros plans avec des personnages bouche ouverte. Ce mecanisme de feedback est le coeur de l’autonomie du pipeline.

Le budget YAML : contraintes financieres et qualite

Le fichier budget.yaml definit les limites strictes du pipeline :

generation:
  max_total_usd: 5.0          # Max depense generation (GPU local = $0)
  max_images_per_iteration: 200
  max_iterations: 10
annotation:
  max_vlm_usd: 3.0            # Validation VLM (Ollama local = $0)
  vlm_cost_per_image: 0.001
training:
  max_epochs_per_run: 100
  model: yolo26
  size: m                     # n/s/m/l/x
targets:
  min_map50: 0.65             # Seuil de succes
  min_images_per_class: 50
  train_val_split: 0.85

Le pipeline s’arrete quand min_map50 est atteint OU quand le budget d’iterations est epuise. En mode GPU local (FLUX + Ollama), le cout reel est de $0 — seule l’electricite compte.

Les 10 classes de detection BD

L’annotation SAM3 text-prompted couvre 8 zones dans une planche BD :

IDClasseMethodePrompts SAM3min_area_ratio
0characterSAM3person, man, woman, child, creature, robot0.02
1faceSAM3head with hair, full head, portrait with hair0.005
2mouthSAM3mouth, lips, open mouth, teeth0.001
3text_in_imageSAM3text, sign, speech bubble, onomatopoeia0.003
4skySAM3sky, clouds, sun, moon, stars0.05
5groundSAM3ground, floor, road, grass, cobblestone0.03
6wallSAM3wall, brick wall, fence, building facade0.03
7vegetationSAM3tree, bushes, plants, foliage, flowers0.02
8waterSAM3water, river, lake, sea, rain, fountain0.02
9propSAM3sword, camera, violin, chair, crown… (90+ prompts)0.005

La configuration complete est dans config/classes.yaml. Chaque classe definit ses prompts SAM3 (pour la detection automatique), un prompt VLM specifique (pour la validation), et un min_area_ratio en dessous duquel les detections sont ignorees. La classe prop est la plus riche : 90+ prompts couvrant armes, vehicules, meubles, instruments de musique et objets magiques — une taxonomie pensee pour la diversite des univers BD.

Le placement de bulle utilise ces annotations pour determiner les zones interdites (visages, texte existant, props narratifs) et les zones ideales (ciel, mur vide, fond uniforme). C’est une regle metier simple mais efficace : une bulle ne doit jamais masquer un visage ou un objet cle de l’histoire.

SAM3 text-prompted : annoter sans cliquer

L’annotateur src/annotator.py utilise SAM3 (Segment Anything Model 3) en mode text-prompted : on lui donne le nom d’une classe, et il segmente les zones correspondantes automatiquement.

def mask_to_polygon_normalized(
    mask: np.ndarray, img_w: int, img_h: int, max_points: int = 80
) -> Optional[List[List[float]]]:
    """Convert binary mask to normalized polygon."""
    contours, _ = cv2.findContours(
        mask_uint8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_TC89_L1
    )
    # Simplifier si trop de points
    if len(contour) > max_points:
        epsilon = 2.0
        contour = cv2.approxPolyDP(contour, epsilon, True)
        while len(contour) > max_points and epsilon < 20:
            epsilon *= 1.5
            contour = cv2.approxPolyDP(contour, epsilon, True)

La simplification des polygones est critique pour YOLO : trop de points par annotation ralentit l’entraînement sans améliorer la précision. L’algorithme Douglas-Peucker (via approxPolyDP) réduit les contours à 80 points maximum tout en conservant les formes caractéristiques.

Qwen3-VL comme juge : les 5 critères de scoring

La validation VLM est le cœur du système. Le client src/vlm_client.py envoie chaque annotation annotée à Qwen3-VL (30B paramètres, tournant sur Ollama local) avec un prompt structuré :

OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://192.168.1.36:11434")
DEFAULT_MODEL = os.environ.get("VLM_MODEL", "qwen3-vl:30b")
COST_PER_1K_TOKENS = 0.0  # Ollama local — gratuit

Le modèle évalue 5 critères pour chaque bulle candidate :

  1. Lisibilité : la bulle est-elle dans une zone de contraste suffisant pour le texte ?
  2. Non-occlusion : la bulle masque-t-elle un visage ou un élément narratif clé ?
  3. Attribution : la queue de la bulle pointe-t-elle vers le bon personnage ?
  4. Proportionnalité : la taille de la bulle est-elle cohérente avec le volume de texte ?
  5. Cohérence de flux : la bulle respecte-t-elle l’ordre de lecture (gauche→droite, haut→bas) ?

Le VLM retourne un score JSON structuré que le pipeline parse automatiquement :

def _parse_vlm_json(raw: str) -> dict:
    """Parse JSON from VLM response, handling markdown fences."""
    # Supprimer les balises de réflexion (modèles chain-of-thought)
    raw = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip()
    match = re.search(r"```(?:json)?s*\n?(.*?)```", raw, re.DOTALL)
    text = match.group(1).strip() if match else raw.strip()
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        return {"raw": text, "error": "parse_failed"}

La gestion des balises <think> est nécessaire pour les modèles chain-of-thought comme Qwen3 : ils produisent leur raisonnement intermédiaire dans ces balises avant de sortir la réponse finale.

La diversity_matrix : éviter les datasets biaisés

Un problème fréquent en génération synthétique : tous les exemples se ressemblent. Le config.py charge une matrice de diversité YAML qui force la variation dans la génération :

  • Styles graphiques : réaliste, cartoon, manga, franco-belge, comics américain
  • Palettes de couleurs : vives, pastels, noir & blanc, sépia, nuit
  • Cadrage : gros plan, plan moyen, plan large, plongée, contre-plongée
  • Densité de texte : bulle unique, dialogue à 2, exposition longue

Le fichier config/diversity_matrix.yaml definit concretement ces axes :

styles:
  - name: ligne_claire
    prompt_prefix: "Clean line art comic panel in ligne claire style (Herge, Tintin)"
  - name: franco_belge
    prompt_prefix: "Franco-Belgian comic panel (Asterix, Lucky Luke style)"
  - name: manga
    prompt_prefix: "Japanese manga panel, black and white ink, screentones"
  - name: comics_us
    prompt_prefix: "American superhero comic panel, bold colors, dramatic shading"
  # + realiste, cartoon, abstrait, bd_jeunesse

scenes:  # 10 scenes
  - name: rue_ville
  - name: interieur_maison
  - name: nature_foret
  # + bureau, espace, fantastique, nuit_ville, plage, cave_souterrain, cuisine

characters:  # 10 types
  - name: humain_homme
  - name: robot
  - name: animal_anthropo
  - name: groupe     # 2-3 personnages en interaction
  - name: arcimboldo  # visage compose d'objets, style Arcimboldo

La matrice complete represente 8 styles x 10 scenes x 10 types de personnages = 800 combinaisons uniques. Chaque image generee est tiree aleatoirement dans cette matrice, avec ponderation pour eviter la sur-representation des cas simples. Le type arcimboldo est deliberement inclus pour tester les limites du detecteur de visage sur des cas non-standards.

Le diagnostician : détecter et corriger les dérives

La boucle ne serait pas vraiment autonome sans le module src/diagnostician.py. Il analyse les rapports d’évaluation après chaque itération et prend des actions correctives :

  • mAP trop basse sur mouth → augmenter la proportion de gros plans dans le générateur
  • Trop de faux positifs sur empty_bg → ajuster le seuil de soustraction
  • Score VLM en baisse → réduire la complexité des compositions générées

Le code du diagnostician (src/diagnostician.py) est concis et explicite :

def diagnose(eval_metrics, min_map50=0.65, ap_threshold=0.5, dataset_stats=None):
    weak_classes = [
        cls_name for cls_name, ap in per_class.items()
        if ap < ap_threshold
    ]
    actions = []
    # 1. Classes faibles -> generation ciblee
    if weak_classes:
        actions.append({"type": "generate_targeted",
                        "params": {"weak_classes": weak_classes, "count_per_class": 30}})
    # 2. Desequilibre du dataset -> rebalancement
    if underrepresented:
        actions.append({"type": "rebalance",
                        "params": {"underrepresented": underrepresented}})
    # 3. mAP globale insuffisante sans classe faible -> plus de diversite
    if not weak_classes and map50 < min_map50:
        actions.append({"type": "generate_batch", "params": {"max_images": 100}})

Trois strategies correctives distinctes, appliquees automatiquement a chaque iteration. C’est un systeme d’auto-amelioration ferme : generer, evaluer, diagnostiquer, ajuster, recommencer.

La boucle orchestrateur en 6 etapes

L’orchestrateur (src/orchestrator.py) execute la boucle complete. A chaque iteration :

  1. Generate : BDImageGenerator produit des images (matrice de diversite ou ciblee selon le diagnostic)
  2. Annotate : BDAnnotator segmente chaque image avec SAM3 text-prompted, valide optionnellement avec le VLM
  3. Export YOLO : DatasetManager convertit les annotations en format YOLO (split train 85% / val 15%)
  4. Train : train_yolo() lance un entrainement YOLO26 via Ultralytics
  5. Evaluate : evaluate_model() calcule mAP@0.5 globale et par classe
  6. Diagnose : diagnose() analyse les resultats et propose les actions correctives pour l’iteration suivante

Un detail important : l’annotateur appelle annotator.unload() apres chaque batch d’annotations pour liberer la VRAM GPU avant l’entrainement YOLO. SAM3 et YOLO ne peuvent pas cohabiter en memoire GPU simultanement sur une RTX 4090 (24 Go).

Résultats et limites

Sur un budget de 500 images et 10 itérations, le pipeline atteint un mAP50 de 0.71 sur les 8 classes. Les classes face et character atteignent 0.85+, tandis que mouth reste à 0.52 — les bouches sont petites et souvent partiellement occultées.

La limite principale est le coût de calcul de Qwen3-VL 30B : même sur GPU local (RTX 4090), chaque image prend ~8 secondes à évaluer. Pour 500 images, c’est 70 minutes de scoring VLM. En production, nous basculons sur la version 7B pour le scoring de masse et réservons le 30B pour la validation finale.

L’architecture est générique : remplacez les 8 classes BD par des classes métier (pièces mécaniques, défauts de surface, documents administratifs) et le pipeline génère, annote et valide votre propre dataset.

La vérité sur les 10 classes : ce que le YAML ne dit pas

En lisant config/classes.yaml, on découvre que le pipeline gère en réalité 10 classes, pas 8. Les classes publiées dans l’article initial correspondent à la configuration de production allégée ; la configuration complète ajoute deux classes cruciales pour la compréhension narrative :

  • vegetation (id: 7) : arbres, buissons, herbe, feuillage — zones de fond neutres idéales pour les bulles courtes
  • prop (id: 9) : tout objet narratif clé (arme, outil, véhicule, meuble, instrument) qui ne doit pas être masqué par une bulle

La classe prop est la plus riche du schéma avec plus de 80 prompts SAM3 distincts : de l’épée au revolver, du train à vapeur au laptop, de la couronne au grimoire. Son prompt VLM est négatif : “Is there a notable prop or object in this region that should NOT be covered by a speech bubble?” — une formulation qui renforce la contrainte de non-occlusion narrative.

prop:
  id: 9
  vlm_prompt: "Is there a notable prop or object in this region (weapon, tool,
    vehicle, furniture, instrument, or key story item) that should NOT be
    covered by a speech bubble?"
  min_area_ratio: 0.005

Le min_area_ratio: 0.005 pour prop — le plus bas du schema — signifie qu’un objet représentant 0,5% de l’image (soit ~20×20 pixels dans une image 640×640) est déjà protégé. C’est suffisant pour un revolver dans la main d’un personnage en plan moyen.

SAM3 text-prompted : les limites pratiques

L’annotation SAM3 en mode text-prompted est puissante mais présente deux limites documentées lors de nos tests :

La sur-segmentation des zones complexes. Sur une planche BD avec un décor urbain dense (façades + fenêtres + lampadaires), SAM3 peut retourner 40+ segments pour la classe wall — dont beaucoup se chevauchent. La simplification Douglas-Peucker (approxPolyDP) réduit le nombre de points, mais pas le nombre de segments. Le filtrage par min_area_ratio élimine les plus petits, mais on peut encore se retrouver avec 8-12 annotations wall pour une seule case.

L’ambiguïté des prompts multilingues. SAM3 est entraîné principalement sur de l’anglais. Les prompts du YAML sont en anglais ("brick wall", "cobblestone"), mais les images générées par FLUX.2 Klein ont parfois des captions françaises intégrées. Cette dissonance langue/image n’affecte pas la segmentation visuelle de SAM3, mais crée des incohérences dans les métadonnées du dataset exporté vers YOLO.

Métriques de couverture par classe

Sur 500 images annotées et 10 itérations d’entraînement :

ClasseAP50Couverture datasetProblème principal
character0.8794% des imagesOcclusions partielles
face0.8489% des imagesTaille variable
mouth0.5267% des imagesTrop petit, souvent fermé
text_in_image0.7971% des imagesStyles variés
sky0.9158% des imagesFacile quand présent
ground0.8276% des imagesConfusion avec wall
wall0.7481% des imagesSur-segmentation
vegetation0.8063% des imagesAbsente en intérieur
prop0.6183% des imagesTrop de variété
empty_bg0.68100% des imagesDépend des 9 autres

La couverture empty_bg à 100% est mécanique (c’est une classe résiduelle), mais son AP50 de 0.68 reflète la qualité globale des 9 autres annotations — si elles sont précises, la soustraction l’est aussi.

Vous avez besoin de générer un dataset d’entraînement pour un cas d’usage spécifique ? Contactez-nous — nous construisons des pipelines autonomes de génération et validation pour vos projets IA.