Generer des Pages de BD Automatiquement : Bulles, Character Sheets et Scoring VLM
Pourquoi generer des pages de BD par programme
Je travaille sur un generateur de roman graphique historique situe en Lorraine. Le recit couvre un siecle d’histoire miniere, avec des dizaines de personnages, des centaines de pages. Dessiner ca a la main prendrait des annees. Le generer entierement par IA produit des resultats incoherents — personnages qui changent de visage, bulles illisibles, mise en page chaotique.
La solution que j’ai construite est un pipeline hybride : l’IA genere les images et le texte, mais le placement, la validation et l’assemblage sont algorithmiques. Le resultat est un systeme de 3 scripts Python qui transforment un chapitre JSON en planche BD au format PDF, avec une coherence visuelle que le pur generatif ne peut pas atteindre.
Les trois pieces du puzzle :
character_sheet_gen.py— genere des fiches personnage multi-vues pour la coherence visuellebubble_placement_test.py— place les bulles de dialogue par simulation physiquebd_page_gen.py— orchestre le pipeline complet de bout en bout
Le pipeline end-to-end en 7 etapes
Le fichier bd_page_gen.py (pres de 100 Ko de code) orchestre tout le processus. L’architecture est documentee directement dans le docstring du module :
Etapes :
0. Gemini genere la config BD (personnages, 6 panels, dialogues, captions)
1. Parse le chapitre JSON
2. Genere les character sheets (front uniquement) via Runware
3. Genere les images de panels avec coherence personnage (referenceImages)
4. SAM3 detection personnages/bouches + VLM validation/assignation
5. Place les bulles de dialogue (physique) sur les panels concernes
6. Rend les bulles (PIL compositing) — texte centre H+V dans les bulles
7. Assemble la planche finale PDF (grille 3x2, format A4, ReportLab) Chaque etape a son cache. Les images, masks SAM3 et detections sont reutilisees si presentes. Seules les bulles et l’assemblage sont recalcules a chaque run. Ca permet d’iterer rapidement sur le placement sans regenerer les images — et les images, ca coute de l’argent.
Step 0 : Gemini comme scenariste de BD
La premiere etape delegue a Gemini (via mammouth.py) la decomposition du chapitre en panels. Le system prompt est un scenariste de BD programme avec des contraintes precises :
_BD_CONFIG_SYSTEM = """Tu es un scenariste de bande dessinee historique, style ligne claire (Tardi, Bourgeon).
Tu dois produire un JSON decrivant exactement {n_panels} panels pour {n_pages} planche(s) BD.
Regles :
- {max_dialogues} dialogues MAX sur l'ensemble des planches
- CHAQUE panel DOIT avoir au moins 1 caption
- COHERENCE VISUELLE : chaque prompt DOIT inclure la description physique COMPLETE
de chaque personnage present (age, vetements, traits, couleur de cheveux, corpulence).
Ne JAMAIS ecrire juste "Heinrich" — toujours "Heinrich (40, stocky miner, grey short hair,
square jaw, dark work jacket, cap, soot-stained face)".
""" Cette contrainte de repetition systematique des descriptions physiques est contre-intuitive pour un humain — on ne decrit pas un personnage a chaque page d’un roman. Mais c’est essentiel pour les modeles T2I qui generent chaque panel independamment, sans memoire.
Le systeme gere aussi les filtres de contenu par modele. GPT Image a un filtre strict sur le contenu militaire — le prompt reformule automatiquement les termes problematiques :
t2i_safety_hint = ""
if t2i_model in ("gpt-image-low", "gpt-image"):
t2i_safety_hint = (
"Les prompts NE DOIVENT PAS contenir : war, Nazi, soldier, military, "
"occupation, prisoner, weapon, blood, death, rifle, bomb, explosion. "
"Reformuler : 'wartime industrial village' -> 'industrial village, 1940s' "
"'Soviet prisoner' -> 'thin exhausted worker'"
) La config generee par Gemini suit un schema JSON strict : characters (avec cle, nom, description), panels (avec prompt T2I anglais, angle de vue, captions multi-lignes), et page_captions pour l’en-tete de planche. Le tout est cache dans bd_config.json et reutilise sauf --regen.
Le systeme gere aussi les chapitres multi-pages (grille 3x2 par planche, 6 a 12 panels) et un mode encyclopedique sans personnages pour les pages documentaires.
Character sheets : la coherence visuelle par reference
Le probleme fondamental de la generation d’images pour la BD, c’est la coherence des personnages. Si vous generez 6 panels independamment, le meme personnage aura 6 visages differents. La solution est de generer d’abord une fiche de reference (character sheet) et de l’injecter comme image de reference dans chaque panel.
character_sheet_gen.py orchestre ca avec un catalogue complet de modeles T2I via l’API Runware :
MODELS = {
"qwen-image": {"id": "runware:108@1", "cost": 0.0058, "img2img": False},
"gpt-image-low": {"id": "openai:4@1", "cost": 0.009, "img2img": True,
"use_referenceImages": True,
"provider_settings": {"openai": {"quality": "low"}}},
"grok": {"id": "xai:grok-imagine@image", "cost": 0.020,
"img2img": True, "use_referenceImages": True},
"nano-banana": {"id": "google:4@1", "cost": 0.039,
"img2img": True, "use_referenceImages": True},
"flux-dev": {"id": "runware:101@1", "cost": 0.003,
"img2img": True, "use_seedImage": True, "seed_strength": 0.60},
"kontext": {"id": "runware:106@1", "cost": 0.0105,
"img2img": True, "use_referenceImages": True,
"overrides": {"steps": 28, "CFGScale": 2.5,
"advancedFeatures": {"guidanceEndStepPercentage": 75}}},
} Le workflow est en deux temps :
- FRONT : text-to-image avec le modele choisi (ex: Grok a $0.02/image)
- BACK, SIDE, 3/4 : img2img avec FRONT comme reference (ex: Kontext a $0.01/image)
Le modele Kontext est le champion de la coherence multi-vues. Son parametre guidanceEndStepPercentage: 75 arrete le guidage textuel a 75% de la diffusion — le modele “finit proprement” sans sur-suivre le prompt en fin de generation.
Deux types d’img2img coexistent dans le code :
use_referenceImages: l’image de reference est passee comme contexte visuel (GPT Image, Grok, Nano-Banana, Kontext)use_seedImage: l’image sert de point de depart pour la diffusion avec unseed_strength(FLUX.1 Dev)
Cout typique pour 4 vues d’un personnage : $0.04 a $0.07.
Validation VLM : le personnage est-il entier ?
Un probleme recurrent avec les modeles T2I : le personnage est coupe — la tete depasse, les pieds sont tronques. La solution est une validation a deux niveaux :
async def _vlm_check_cropped(image_path: Path) -> bool:
# Step 1: analyse pixel — variance des bords de l'image
img = Image.open(image_path).convert("RGB")
arr = np.array(img)
def edge_has_content(rows):
"""High variance = character body touches the edge."""
variance = np.var(rows.astype(float), axis=(0, 2)).mean()
return variance > 500 # uniform bg ~0, character ~1000+
top_content = edge_has_content(arr[:3, w//4:3*w//4])
bottom_content = edge_has_content(arr[-3:, w//4:3*w//4])
if top_content or bottom_content:
return True # probably cropped
# Step 2: VLM en backup
result = await _vlm_analyze_deepinfra(image_path,
'Can you see the TOP of the head? Can you see the FEET? '
'Respond: {"head_visible": true/false, "feet_visible": true/false}')
return not result.get("head_visible") or not result.get("feet_visible") Le premier niveau est purement algorithmique : si les pixels du bord central ont une variance elevee (> 500), du contenu non-uniforme (le personnage) touche le bord de l’image. C’est rapide et gratuit. Le VLM n’est appele qu’en backup.
Si le personnage est coupe, le systeme regenere avec un seed different (jusqu’a 3 tentatives), puis bascule sur un modele fallback :
fallback_models = [t2i_model]
for fb in ["grok", "nano-banana"]:
if fb != t2i_model and fb in MODELS:
fallback_models.append(fb) D’autres validations VLM completent le pipeline : _vlm_check_style detecte les images trop photorealistes, et _vlm_check_bubbles detecte les bulles de dialogue generees par l’IA (qui sont toujours illisibles et doivent etre evitees).
Le placement de bulles : un probleme de physique
C’est la partie la plus interessante du pipeline. Placer des bulles de dialogue sur une case de BD est un probleme sous contraintes multiples :
- Les bulles doivent etre proches de la bouche du personnage qui parle
- Elles ne doivent pas masquer les visages (zone protegee)
- Elles ne doivent pas se chevaucher entre elles
- L’ordre de lecture doit etre respecte (gauche-droite, haut-bas)
- Elles doivent rester dans les limites du panel
La solution dans bubble_placement_test.py est une simulation physique avec 6 types de forces.
Les structures de donnees
Les dataclasses sont simples et bien concues :
@dataclass
class Rect:
x: float; y: float; w: float; h: float
def overlaps(self, other: "Rect") -> bool:
return not (self.right <= other.x or other.right <= self.x
or self.bottom <= other.y or other.bottom <= self.y)
def overlap_area(self, other: "Rect") -> float:
ox = max(0, min(self.right, other.right) - max(self.x, other.x))
oy = max(0, min(self.bottom, other.bottom) - max(self.y, other.y))
return ox * oy
@dataclass
class Speaker:
name: str
body_bbox: Rect
mouth_pos: Tuple[float, float]
body_crop: float = 0.0 # fraction du corps hors-cadre
@property
def head_rect(self) -> Rect:
"""Zone protegee autour de la tete (jamais masquee par une bulle)."""
mx, my = self.mouth_pos
head_bottom = min(my + 40, self.body_bbox.bottom)
return Rect(self.body_bbox.x, self.body_bbox.y,
self.body_bbox.w, head_bottom - self.body_bbox.y)
@dataclass
class PlacedBubble:
rect: Rect
dialogue: Dialogue
tail_start: Tuple[float, float]
tail_end: Tuple[float, float]
elastic_ratio: float = 1.0 # current_w / natural_w (< 1 = squeezed)
font_scale: float = 1.0 # < 1 = smaller text Le Speaker a une propriete visible_rect qui gere le cropping — quand un personnage est partiellement hors-cadre, seule la partie visible bloque les bulles.
Les 6 forces du systeme
def place_bubbles_physics(
dialogues: List[Dialogue],
panel: Rect,
speakers: List[Speaker],
font,
max_iter: int = 600,
dt: float = 1.0,
damping: float = 0.80,
k_mouth: float = 0.035, # F1: ressort vers la bouche
k_body: float = 8.0, # F2: repulsion corps solide (MTV)
k_bubble: float = 40000.0, # F3: repulsion EM bulle-bulle
k_lex_anchor: float = 0.02, # F4: ressort vers ancre lexicographique
k_lex_pair: float = 0.12, # F5: correction pairwise ordre de lecture
k_gravity: float = -0.02, # F6: gravite vers le haut
elastic_min: float = 0.6, # compression minimum
elastic_max: float = 1.5, # expansion maximum
font_scale_min: float = 0.65, # taille police minimum
) -> List[PlacedBubble]: F1 — Ressort vers la bouche : chaque bulle est attiree vers un point au-dessus de la bouche du locuteur. Le coefficient k_mouth=0.035 est volontairement faible — la bulle peut s’eloigner si l’espace l’impose.
mx, my = sorted_d[i].speaker.mouth_pos
forces[i][0] += k_mouth * (mx - bcx)
forces[i][1] += k_mouth * (my - bh - 25 - bcy) F2 — Repulsion des corps : les personnages sont des corps solides. Quand une bulle chevauche un personnage, le Minimum Translation Vector (MTV) calcule le plus petit deplacement qui resout le chevauchement — la meme technique que dans les moteurs de jeux video :
def compute_mtv(a: Rect, b: Rect) -> Tuple[float, float]:
if not a.overlaps(b):
return (0, 0)
left = a.right - b.x
right = b.right - a.x
top = a.bottom - b.y
bottom = b.bottom - a.y
m = min(left, right, top, bottom)
if m == left: return (-left, 0)
if m == right: return (right, 0)
if m == top: return (0, -top)
return (0, bottom) F3 — Repulsion electromagnetique bulle-bulle : les bulles se repoussent avec une force en 1/r², comme des charges electriques. Le coefficient eleve (k_bubble=40000.0) garantit une separation nette :
dist_sq = dx * dx + dy * dy
s = k_bubble / (max(dist_sq, 100) * m)
forces[i][0] += s * dx / dist
forces[i][1] += s * dy / dist F4/F5/F6 — Systeme lexicographique : c’est l’innovation centrale. La BD se lit de gauche a droite, de haut en bas. Chaque bulle a une “ancre lexicographique” positionnee sur une grille virtuelle en haut du panel :
# Position lex anchor : proportionnelle a l'ordre du dialogue
frac = sorted_d[i].order / max(n - 1, 1)
lex_bias_x = (frac - 0.5) * panel.w * 0.3
# Correction pairwise : si bulle i (avant j) se retrouve a droite de j
same_row = abs(bcy - jcy) < max(bh, sizes[j][1]) * 0.6
if same_row and bcx > jcx + 5:
forces[i][0] += k_lex_pair * (jcx - 5 - bcx) / m Les bulles anterieures ont une masse plus elevee (mass_base=1.5, decrementee de 0.15 par position) — plus difficiles a deplacer. Une gravite douce pousse tout vers le haut du panel, la ou les bulles sont conventionnellement placees en BD.
Trois niveaux de deformation
Quand l’espace est trop contraint, le systeme dispose de trois leviers, par ordre de priorite :
- Position (forces physiques) — deplacer la bulle
- Largeur elastique — retrecir/elargir la bulle entre 60% et 150% de sa largeur naturelle, le texte se re-wrap automatiquement
- Taille de police — reduire la fonte jusqu’a 65% du nominal (dernier recours)
# Re-wrap du texte pour la largeur elastique courante
def bubble_size_for_text_elastic(text, font, target_width, padding=14):
inner_w = max(30, target_width - 2 * padding)
lines = _wrap(text, font, draw, inner_w)
# ... calcul hauteur resultante
return (text_w + 2 * padding, text_h + 2 * padding) La simulation tourne 600 iterations maximum avec convergence anticipee (quand le deplacement max passe sous 0.3 pixels). Puis une passe de contraintes dures (200 iterations) resout les chevauchements residuels — en generant des candidats de sortie dans les 4 directions + coins, filtrés par validite et choisis par proximite a la bouche du locuteur.
Step 4 : SAM3 + VLM pour localiser les personnages
Avant de placer les bulles, il faut savoir ou sont les personnages. Le pipeline utilise SAM3 (Segment Anything Model 3) pour la detection, avec le VLM comme couche d’interpretation.
D’abord, le VLM identifie les objets importants de la scene :
base_prompts = ["person", "face", "mouth", "open mouth", "chin"]
vlm_obj_prompt = (
"List the IMPORTANT visual objects in this comic panel image. "
'Return JSON: {"objects": ["steel headframe", "mining cage", ...]}'
)
vlm_objects = await vlm_analyze(panel_path, vlm_obj_prompt)
for obj in extra_prompts[:10]:
base_prompts.append(obj) SAM3 detecte ensuite avec ces prompts et retourne des bounding boxes, un masque d’occupation et un masque de zones libres :
detections, occupied_mask, free_mask = await asyncio.to_thread(
sam3_detect_with_masks, panel_path, object_prompts, 0.10) Le detail malin : avant de lancer SAM3 (qui demande ~2 Go de VRAM), le systeme decharge le modele VLM Ollama pour liberer la GPU :
await hc.post("http://localhost:11434/api/generate",
json={"model": "qwen3-vl:30b", "keep_alive": 0})
await asyncio.sleep(2) Puis le VLM fait l’assignation — il recoit l’image annotee avec des bboxes numerotees et mappe chaque detection a un personnage :
vlm_prompt = f"""This image has colored bounding boxes drawn on it.
Detections: {det_list_str}
Characters: {char_list_str}
For each character, which "person" box and which "mouth" box?
Respond: {{"assignments": [...]}}""" La position de la bouche suit une cascade de priorites : SAM3 mouth > VLM estimate > face bottom (75% hauteur) > body top (15% hauteur). Chaque fallback est moins precis mais garantit qu’on a toujours une position.
Le style visuel et l’anti-bulle
Tous les prompts T2I partagent un style constant :
STYLE = (
"Soft watercolor illustration in the style of a Franco-Belgian graphic novel. "
"Gentle thin ink outlines, soft pastel washes, warm muted tones (sepia, soft ochre, "
"dusty blue, warm grey). Delicate brushstrokes, watercolor paper texture visible."
)
NO_BUBBLES = (
"CRITICAL: absolutely NO speech bubbles, NO text, NO letters, NO words, "
"NO dialogue balloons, NO writing of any kind anywhere in the image."
) L’interdiction absolue de bulles dans le prompt T2I est cruciale. Les bulles generees par l’IA sont toujours moches — texte illisible, queues mal orientees, tailles aleatoires. Le systeme genere des images sans aucun texte, puis compose les bulles en post-processing avec PIL, ce qui donne un controle typographique total.
L’assemblage final
La planche est une grille 3x2 sur format A4 a 150 DPI :
PAGE_W, PAGE_H = 1240, 1754
GUTTER = 10
BORDER = 2 Pour concatener les planches en album :
gs -dBATCH -dNOPAUSE -sDEVICE=pdfwrite -dPDFSETTINGS=/ebook
-sOutputFile=planches_complet.pdf planche_ch1.pdf planche_ch2.pdf ... Couts de production
Pour une planche de 6 panels avec 3 personnages et 3 dialogues :
- Config Gemini : ~$0.01
- Character sheets (3 personnages, front only) : ~$0.06
- Panels (6 images) : ~$0.12 a $0.24 selon le modele
- VLM validations (~10 appels) : ~$0.05
- Total : $0.24 a $0.36 par planche
Pour un roman graphique de 100 planches : $24 a $36. Pas gratuit, mais 1000x moins cher qu’un illustrateur professionnel.
Ce qui marche et ce qui ne marche pas
Ce qui marche :
- Le placement de bulles par physique est robuste — il gere 4-5 bulles par panel sans chevauchement
- Les character sheets ameliorent significativement la coherence inter-panels
- La validation VLM catch les images problematiques (personnages coupes, style incorrect, bulles parasites)
- Le systeme de fallback multi-modeles est resilient
Ce qui ne marche pas encore :
- La coherence inter-panels reste approximative malgre les character sheets — les expressions faciales varient
- SAM3 echoue sur les plans larges ou les personnages occupent moins de 10% de l’image
- Le re-wrap elastique produit parfois des bulles trop hautes et etroites sur 4+ dialogues
Position assumee : ce pipeline produit des storyboards avances, pas de la BD professionnelle. Suffisant pour un roman graphique web ou un prototype. L’humain reste dans la boucle pour la direction artistique — l’IA fait le gros oeuvre.
Un projet similaire ? Contactez Loick Briot : contact@brio-novia.eu