Un lundi, une story part avec « LESSlVE » écrit sur le flacon, un L minuscule à la place du I, dessiné par le modèle d'image et passé inaperçu à la relecture sur téléphone. Trois commentaires moqueurs plus tard, le post est retiré. Le relecteur humain reste indispensable, mais il ne doit voir que des visuels qui ont déjà passé les contrôles qu'une machine fait mieux que lui : mesurer un contraste, comparer des couleurs, repérer une lettre dans un coin.
Le mécanisme
Trois contrôles par visuel, chacun avec un seuil et une valeur enregistrée :
| Contrôle | Méthode | Seuil | Coût |
|---|---|---|---|
| Contraste du texte | WCAG 2.x, sur le rendu final, au percentile (leçon précédente) | ≥ 4,5:1 | 0 |
| Respect de la palette | 6 couleurs dominantes (quantification), distance CIE76 à la couleur de palette la plus proche, pondérée par la surface | ≤ 28 | 0 |
| Texte parasite | le modèle de texte regarde le fond (sans calques) et répond selon un schéma {contient_texte, elements} | aucun texte | ≈ 0,0004 $ |
flowchart TD
F["Fond généré"] --> P["distance_palette()"]
F --> X["texte_parasite() : gemini-3.5-flash-lite + schéma"]
R["Rendu avec calques"] --> C["contraste mesuré à la mise en page"]
P --> A{"les 3 contrôles passent ?"}
X --> A
C --> A
A -- "texte détecté" --> E["édition : « retire tout texte (éléments) »<br/>max 2 tours"]
E --> F
A -- "oui" --> G["à valider en galerie"]
A -- "non, après 2 tours" --> H["à valider, contrôle en rouge :<br/>décision humaine"]
Le détecteur regarde le fond avant les calques : sinon il signalerait le titre que le code vient de poser. Et la correction ne se fait pas en régénérant : elle se fait en éditant, dans la conversation du format, avec la liste des éléments que le détecteur a vus.
La mise en œuvre
Le contrôle de texte parasite, dans qc.py :
class TexteParasite(BaseModel):
contient_texte: bool = Field(description="vrai si l'image contient des lettres, chiffres, mots, logos ou étiquettes lisibles")
elements: list[str] = Field(description="liste courte de ce qui est lisible et où, vide sinon")
PROMPT_TEXTE = ("Examine cette image publicitaire. Contient-elle du texte généré DANS l'image : lettres, mots, chiffres, "
"pseudo-écriture, étiquette ou logo dessinés ? Ignore les formes abstraites. Réponds selon le schéma.")La boucle de correction, dans pipeline.py :
for _ in range(self.s.max_tours_qc):
a = self.manifest["assets"][nom]
if a["qc"]["texte_parasite"]["ok"]:
return
elements = "; ".join(a["qc"]["texte_parasite"]["elements"]) or "texte détecté"
consigne = f"Retire tout texte, lettre, chiffre, étiquette ou logo visible ({elements}). Ne change rien d'autre ..."
fond = self.m.editer(self._session(nom), consigne, image=..., aspect=FORMATS[nom].aspect)
self._nouvelle_version(nom, fond, consigne="QC auto : " + consigne)Un détecteur qui ne détecte jamais rien ressemble exactement à un détecteur qui fonctionne. Sur les quatre visuels de la campagne, aucun texte n'est apparu : la boucle n'a donc pas tourné. Pour prouver qu'elle tourne, le studio a une sonde : il imprime lui-même une fausse étiquette « LESSIVE / 40 lavages » sur un fond propre, demande au modèle s'il voit du texte, demande l'effacement, et recontrôle.
make test # test_campagne_complete_avec_correction_auto_du_texte_parasite (faux modèle)
.venv/bin/python -m studio sonde-qc # la sonde, avec les vrais modèles (≈ 0,07 $)Résultat obtenu
Trois exécutions réelles de la sonde le 2026-10-15 (Gemini API deux fois, Vertex AI une fois), même résultat :
sonde QC : étiquette piégée détectée=True ['LESSIVE', '40 lavages'] fond propre détecté=False après édition détecté=FalseEt les contrôles des quatre visuels du second test de bout en bout :
linkedin 1200x627 v1 contraste 8.68:1 palette 9.8 texte parasite non QC OK
instagram_portrait 1080x1350 v1 contraste 13.34:1 palette 15.5 texte parasite non QC OK
story 1080x1920 v1 contraste 12.28:1 palette 15.4 texte parasite non QC OK
banniere 1584x396 v1 contraste 8.72:1 palette 10.3 texte parasite non QC OKTrois sondes concluantes ne mesurent pas un taux de détection. Elles prouvent que la chaîne est branchée. Pour mesurer le rappel, il faut un jeu de 50 à 100 fonds annotés à la main, dont une partie avec du texte réel du modèle.
Ce qui casse en production
| Symptôme | Cause | Parade |
|---|---|---|
| Une pseudo-écriture minuscule sur un bouchon passe le contrôle | l'image est envoyée entière ; quelques pixels de texte ne pèsent rien | pour les formats verticaux, envoyer aussi deux moitiés recadrées ; le prompt mentionne explicitement « pseudo-écriture » |
| Le détecteur signale le titre comme texte parasite | contrôle fait sur le rendu au lieu du fond | contrôler fond, jamais rendu |
| Écart à la palette de 22,3 sur la bannière du premier essai, contre 10 ailleurs | fond rosé, plus saturé que la palette crème | le seuil (28) laisse passer ; la valeur reste visible en galerie pour l'humain. Le seuil est empirique : recalibrez-le sur 20 visuels jugés par la marque |
| Boucle d'édition qui ne converge pas | le modèle redessine du texte à chaque tour | MAX_TOURS_QC=2, puis contrôle en rouge et décision humaine ; jamais de boucle sans borne |
La règle de décision
Automatisez les contrôles mesurables et bornez les corrections automatiques. Ne transformez jamais un contrôle en blocage silencieux : un visuel qui échoue arrive en galerie avec son contrôle en rouge et la raison, et c'est un humain qui tranche. Surveillez le taux de visuels qui passent du premier coup ; s'il baisse après une mise à jour de modèle, la sonde dira si le détecteur ou le générateur a changé.
Pratique
Après une mise à jour du modèle de texte, 100 % des visuels passent le contrôle texte parasite pendant une semaine, contre 85 % avant. Que faites-vous ?
Une amélioration brutale d'un indicateur est aussi suspecte qu'une dégradation. Le modèle d'image n'a pas changé : la cause probable est le détecteur. La sonde tranche en une minute pour 0,07 $.
Le contrôle palette échoue (distance 41) sur un visuel que la responsable de marque trouve parfait. Quelle décision ?
Le seuil est un réglage empirique, le jugement humain fait foi. L'approbation reste possible et tracée ; le cas sert à recalibrer. Régénérer pour satisfaire un seuil mal réglé coûte et dégrade un visuel jugé bon.
Exercice. Ajoutez un quatrième contrôle : la netteté du fond (un fond flou trahit souvent un agrandissement). Mesurez la variance du laplacien sur l'image en niveaux de gris et refusez sous un seuil.
Voir la solution
# qc.py
from PIL import ImageFilter
def nettete(img: Image.Image) -> float:
g = img.convert("L").resize((512, int(512 * img.height / img.width)))
lap = np.asarray(g.filter(ImageFilter.Kernel((3, 3), [0, 1, 0, 1, -4, 1, 0, 1, 0], scale=1, offset=128)), dtype=float)
return round(float(lap.var()), 1)
# dans controler()
n = nettete(img)
res["nettete"] = {"valeur": n, "ok": bool(n >= SEUIL_NETTETE)}Calibrez SEUIL_NETTETE sur les fonds réels de vos campagnes (mesurez-en une vingtaine, nets et flous), puis ajoutez une ligne dans la carte de la galerie. Test : une image effect_noise doit passer, la même image floutée par GaussianBlur(8) doit échouer.
- Je sais pourquoi on contrôle le fond et non le rendu pour le texte parasite
- Je sais mesurer l'écart d'une image à une palette
- Je sais borner une boucle de correction automatique
- Je sais prouver qu'un détecteur fonctionne avec une sonde