Le service juridique d'Ondée demande, la veille du lancement, de remplacer « 0,19 € le lavage » par « 0,19 € le lavage, prix constaté en magasin ». Si le prix est dessiné dans l'image par le modèle, il faut régénérer les quatre visuels, revalider, et espérer que le modèle écrive juste. Si le texte est un calque posé par le code, la modification prend une seconde, ne coûte rien et ne peut pas introduire de faute d'orthographe.
Le mécanisme
Le modèle livre un fond. Le code fait le reste, dans cet ordre :
flowchart TD F["Fond généré (ratio approché)"] --> R["ImageOps.fit : recadrage au format exact"] R --> Z["Zone de texte : bas ou gauche, marges, zone sûre de l'application"] Z --> M["Mesure : contraste du texte contre le pire 5 % des pixels de la zone"] M -- "≥ 4,5:1" --> T["Texte en couleur de marque"] M -- "< 4,5:1" --> V["Voile dans la couleur opposée, nouvelle mesure"] V --> T T --> L["Logo en haut à droite"] L --> S["Rendu + mesure enregistrés dans le manifeste"]
Le point délicat est la mesure. Une photo n'a pas une couleur de fond : la zone sous le titre contient un mur clair, une ombre, un reflet. Le contraste calculé sur la moyenne de la zone est trompeur. Le studio prend le 95e centile de luminance si le texte est clair (le pire fond est le plus clair) et le 5e centile si le texte est sombre.
La mise en œuvre
La mesure, dans qc.py :
def contraste_zone(img, boite, couleur_texte) -> float:
"""Contraste du texte contre le pire 5 % des pixels de la zone (pas la moyenne)."""
zone = np.asarray(img.convert("RGB").crop(boite), dtype=float).reshape(-1, 3)
lum = luminance(zone)
lt = float(luminance(couleur_texte))
pire = np.percentile(lum, 95) if lt > float(np.median(lum)) else np.percentile(lum, 5)
return round(float((max(lt, pire) + 0.05) / (min(lt, pire) + 0.05)), 2)Le choix de couleur et le voile, dans layout.py :
# Uniquement « texte » ou « fond » de la palette : le kit garantit 4,5:1 entre elles.
couleur = max((kit.couleur("texte"), kit.couleur("fond")), key=lambda c: qc.contraste_zone(img, boite, c))
mesure = qc.contraste_zone(img, boite, couleur)
if mesure < qc.SEUIL_CONTRASTE:
voile_c = max((kit.couleur("primaire"), kit.couleur("texte"), kit.couleur("fond")),
key=lambda c: qc.contraste(c, couleur))
# rectangle arrondi, opacité 214/255, puis nouvelle mesure sur l'image composéeLe logo est un monogramme dessiné par le code (carré arrondi primaire, initiale en couleur de fond). Une vraie marque remplacera la fonction logo() par le chargement de son fichier PNG ou SVG rastérisé ; le reste ne change pas.
Changer une accroche ou la mise en page ne demande aucun appel au modèle :
make test # test_format_exact_et_contraste_aa : 4 formats sur un bruit aléatoire, tous ≥ 4,5:1 avec voile
make recomposer # repose texte et logo sur les fonds existants ; les visuels repassent « à valider »Résultat obtenu
make recomposer sur la campagne réelle du 2026-10-15 : 0,7 s, 0 appel, et les contrastes mesurés après passage de « noir générique » à « couleur texte de la marque » :
| Format | Avant (texte #111111) | Après (texte #2B1D16 de la marque) | Voile |
|---|---|---|---|
| LinkedIn 1200x627 | 4,91:1 | 12,56:1 | oui, ajouté |
| Instagram 1080x1350 | 13,20:1 | 11,38:1 | oui |
| Story 1080x1920 | 14,03:1 | 12,09:1 | oui |
| Bannière 1584x396 | 6,01:1 | 5,16:1 | non |
Le LinkedIn est l'enseignement : avec le noir générique, il passait de justesse sans voile (4,91:1). Avec la couleur de marque, légèrement plus claire, il serait tombé sous le seuil ; le code l'a détecté et a posé un voile. Personne n'a eu à regarder.
Ce qui casse en production
Quatre défauts observés sur le premier rendu réel, tous corrigés dans le code actuel :
| Symptôme observé | Cause | Parade |
|---|---|---|
| Le texte de la story est caché par le champ « Envoyer un message » d'Instagram | le bloc était collé au bas de l'image | zone_sure=0.14 : 14 % de la hauteur réservés en haut et en bas, comme le recommande Meta pour les stories |
| « 0,19 » en fin de ligne, « € le lavage » à la ligne suivante | césure sur l'espace avant le symbole | insecables() : espaces insécables avant € : ; ! ? % et après un nombre ; découpe avec split(" "), car split() coupe aussi sur l'insécable |
| Sous-titre de la bannière à 18 px, illisible | corps calculé sur la plus petite dimension (396 px) | pour les ratios > 3:1, le corps se règle sur la hauteur (14 %) |
| Logo invisible sur la diapositive à fond primaire | monogramme primaire sur aplat primaire | logo inversé quand le contraste entre l'aplat et la primaire est < 3:1 |
La règle de décision
Tout ce qui doit être exact (prix, dates, mentions légales, nom de marque) est un calque. Le modèle d'image ne dessine que ce qui peut être approximatif. Mesurez le contraste sur l'image finale, au percentile, pour chaque rendu, et enregistrez la valeur : c'est la preuve à présenter en cas d'audit d'accessibilité.
Pratique
Un titre blanc est posé sur un ciel bleu moyen avec un nuage blanc dans le coin de la zone. Le contraste moyen de la zone vaut 5,2:1. Que conclut le studio ?
La moyenne dilue le nuage. Les lettres qui passent sur le nuage sont blanches sur blanc. Le percentile capture ce pire cas. Le noir n'est pas toujours meilleur : sur un ciel moyen, le blanc avec voile peut garder l'identité de la marque.
La marque impose son orange #E07A3F pour les titres. Sur le fond crème #F6F1E7, le contraste vaut environ 2,7:1. Que proposez-vous ?
Une charte qui produit du texte illisible se corrige avec la marque, pas en silence. L'orange reste visible là où il ne porte pas de texte. Assombrir automatiquement change la couleur de marque sans décision humaine.
Exercice. Ajoutez une mention légale obligatoire en petit corps (« Prix constaté en magasin, novembre 2026 ») en bas à droite de chaque visuel, avec le même contrôle de contraste.
Voir la solution
Dans composer(), après le logo :
f_m = police(kit, fonts_dir, False, max(14, int(min(W, H) * 0.022)))
mention = insecables("Prix constaté en magasin, novembre 2026")
lm = d.textlength(mention, font=f_m)
bm = (int(W - marge - lm), H - marge - sure - f_m.size, W - marge, H - marge - sure)
c_m = max((kit.couleur("texte"), kit.couleur("fond")), key=lambda c: qc.contraste_zone(img, bm, c))
if qc.contraste_zone(img, bm, c_m) < qc.SEUIL_CONTRASTE:
... # même voile que pour le titre, sur bm
d.text((bm[0], bm[1]), mention, font=f_m, fill=c_m)Enregistrez la mesure dans le dictionnaire renvoyé ("contraste_mention") et ajoutez-la au contrôle qualité : la mention légale est du texte, le critère 4,5:1 s'y applique aussi.
- Je sais recadrer une image générée au format exact sans la déformer
- Je sais mesurer un contraste au percentile sur une photo
- Je sais réserver la zone sûre d'une story
- Je sais modifier un texte publié sans régénérer d'image (
make recomposer)