5 août 2026
Le décodeur ment : mettre en production des QR codes halftone qui scannent vraiment
Retour d'expérience de sqr.art, un générateur de QR codes
artistiques dont le moteur, sqrart/qart,
est open source (MIT).
Notre oracle logiciel décodait chaque code produit. Version, masque, correction d'erreur — tout cohérent, tout lisible. On sort une série, on en imprime un à 4 cm, on pointe un téléphone dessus, et il refuse de scanner.
Cet écart — entre un décodeur lit le fichier et un téléphone lit l'impression — c'est toute l'histoire de la mise en production des QR codes halftone. Les papiers résolvent l'encodage. Personne ne te prévient pour la caméra.
Ce n'est pas un tutoriel sur le halftone QR. La technique remonte à un papier SIGGRAPH Asia de 2013 et il existe plusieurs implémentations libres. C'est ce qu'on a appris après que l'encodage a marché.
Ce qu'est vraiment un QR code halftone
Un « QR à logo » classique colle une image par-dessus un code qui fonctionne et compte sur la correction d'erreur pour survivre aux dégâts. Un QR halftone, c'est l'inverse : l'image est le code. Chaque module est poussé vers la luminance locale de l'image cible, et ceux qui casseraient le code sont remis dans le rang en résolvant un système linéaire sur GF(2) — élimination gaussienne sur le corps à deux éléments, avec les bits de données et de Reed–Solomon comme variables libres et les bits des repères / du timing / du format fixes.
Dans notre moteur, les pivots sont ordonnés par importance visuelle : les modules qui comptent le plus pour l'image sont résolus en premier, pour que les écarts tombent là où l'œil les voit le moins. L'URL est encodée dans les codewords ; l'image vit dans la liberté que laisse la correction d'erreur.
La conséquence qui compte ici : il n'y a aucune marge de sécurité. Un QR à logo a de la correction d'erreur redondante qui dort. Un QR halftone dépense cette redondance dans l'image. Quand le monde physique grignote un peu de contraste, le QR à logo hausse les épaules et le QR halftone meurt.
L'oracle n'est pas une caméra
Avant de livrer un code, on le redécode et on vérifie l'URL. Concrètement :
$result = (new QRCode(new QROptions([
'readerUseImagickIfAvailable' => false,
])))->readFromFile($png);
return $result->data === $expectedUrl;
C'est chillerlan/php-qrcode qui lit un PNG impeccable — la librairie même qui
sert de référence de rendu, utilisée ici comme oracle. S'il fait l'aller-retour,
le code est mathématiquement correct.
Un téléphone est une tout autre machine. Il voit un rectangle imprimé à travers une optique bon marché, de biais, sous la lumière de la cuisine, puis applique un seuillage adaptatif pour décider quelles cellules sont noires. L'oracle logiciel et le téléphone échouent sur des choses différentes, et — c'est le piège — l'oracle n'est pas strictement le plus sévère des deux. Il rejette des codes qu'un téléphone pardonnerait, et il pardonne des codes qu'un téléphone rejette. Passer l'oracle te dit que les maths sont justes. Ça ne te dit rien de l'impression.
On a passé un vrai temps à construire un banc logiciel « plus dur » — nourrir le décodeur de flou et de bruit synthétiques pour simuler une caméra. Il n'a jamais reproduit un seul échec terrain. Le flou synthétique n'est pas de l'optique ; un noyau gaussien n'est pas un téléphone qui binarise une impression peu contrastée. Chaque bug ci-dessous a été trouvé sur papier, avec un téléphone, et n'a pas pu être reproduit en logiciel.
Les échecs, dans l'ordre où on les a rencontrés
1. Les points en losange portaient moitié moins d'encre qu'on croyait
On propose un style de module « losange » — chaque cellule dessinée comme un carré tourné. C'était superbe à l'écran et ça scannait mal sur papier. La raison était géométrique, pas optique : un losange inscrit dans une cellule couvre la moitié de son aire. Chaque module sombre portait la moitié de l'encre du carré qu'il remplaçait, donc aux tailles d'impression le seuil du téléphone les perdait.
Le correctif tient en une ligne — mettre à l'échelle la demi-diagonale du losange par √2 pour que son aire égale le carré qu'il représente :
public function span(float $d): float
{
return $this === self::Diamond ? $d * M_SQRT2 : $d;
}
Même style visuel, deux fois plus d'encre. Ça a scanné.
2. Les logos à aplats n'offrent aucune prise
Une photo scanne plus fiablement qu'un logo plat — l'inverse de ce qu'on croirait. Une photographie est pleine de contraste local, donc même là où un module est « faux » pour le code, il y a de la texture autour pour que le téléphone s'accroche. Un logo de marque à aplats est un champ uniforme : une grande zone de modules identiques avec quelques modules discordants qui portent le code. Imprimé, ce champ uniforme tombe pile sur le seuil de binarisation du téléphone, et les rares modules discordants n'ont aucun contraste alentour pour ancrer le seuillage adaptatif. Le scanner n'arrive pas à décider, alors il abandonne.
On détecte désormais les sources à aplats en amont — une image est « plate » quand deux niveaux de luminance dominent son histogramme — et pour celles-là on force la correction d'erreur maximale (ECC-H) et un budget d'erreur nul : aucun module n'a le droit de dériver au profit de l'image, car sur un champ plat chaque module sacrifié est un module que le téléphone ne peut pas récupérer. C'est un échange délibéré d'un peu de fidélité contre un code qui survit à une imprimante laser.
3. Les URL courtes épousent l'image de trop près
Pour caser plus d'image, on gère un mode d'URL « courte » où la solution vit dans le padding des codewords au lieu d'être redérivée par l'oracle. C'est plus fidèle — et c'est exactement le problème. Le padding se conformait si bien à l'image que de grandes plages unies se retrouvaient sans aucun module discordant. Pas de module discordant, pas d'ancre pour le seuillage adaptatif : le même échec que le logo à aplats, arrivé par une autre porte. Une photo qui scannait très bien en mode « complet » ne scannait pas en mode « court » à la même taille.
Aucune de nos heuristiques d'image ne séparait proprement les bons codes courts des mauvais. Alors on a arrêté d'essayer d'être malins et on a acheté de la marge à la place : ECC-H par défaut dès la version 15. L'URL représente quelques pour cent de la capacité d'un v15 ; la correction en rab est quasi gratuite, et tous les échecs terrain qu'on avait vus étaient en ECC faible. Achète la marge ; n'essaie pas de déjouer la caméra.
4. Le plancher d'impression
En dessous d'environ 4 cm à 300 dpi, rien de dense ne survit. Un code v15 a des modules ~1,9× plus petits qu'un v10, et sous le plancher ces cellules tombent en dessous de ce que l'optique et la binarisation d'un téléphone résolvent. Pas de correctif logiciel ; c'est de la physique. La règle pratique qu'on livre : garde les codes denses grands, et quand le design impose petit, baisse la version.
Aparté sur « trop lent »
Longtemps on a cru les versions élevées hors de portée en calcul — l'élimination GF(2) semblait exploser au-delà de v20. Faux. Le profilage a montré que le coût venait d'une sonde de décodeur cachée qu'on lançait, pas de l'élimination elle-même. Le XOR de PHP sur des entiers compactés est déjà à vitesse C pour ça ; l'algèbre linéaire n'a jamais été le goulot. On a même construit un chemin Rust en FFI pour l'élimination et on s'est aperçu qu'on n'en avait presque pas besoin — sortie identique octet pour octet, gains marginaux. La leçon rime avec le reste : mesure la vraie chose avant d'optimiser celle que tu imagines.
Le seul test qui compte
Tout ce qui précède a renforcé la même conviction : le test qui fait foi, c'est une planche imprimée et un vrai téléphone. On livre une commande de calibrage qui compose une feuille A4 à 300 dpi avec une douzaine de variantes à tailles physiques fixes — dont des témoins volontairement fragiles (un v15 en ECC faible) censés échouer, pour que la planche borne la zone sûre au lieu de simplement confirmer le cas heureux. Tu l'imprimes, tu scannes chaque vignette au téléphone, tu notes ce qui a flashé. L'oracle logiciel reste dans la chaîne comme garde-fou de correction, mais il n'a jamais voix au chapitre sur la scannabilité.

La planche qu'on imprime vraiment : chaque code à 40 mm, des variantes sûres à côté de témoins bâtis pour échouer, pour qu'une session de scan borne la zone sûre au lieu de confirmer le cas heureux. Télécharger le PDF prêt à imprimer (A4, 300 dpi) — imprime-le à 100 %, scanne chaque vignette au téléphone, et note ce qui flashe.
À retenir
- Un QR halftone dépense son budget de correction d'erreur dans l'image : il ne lui reste aucune marge pour le monde physique. Traite-le comme fragile par défaut.
- Un décodeur logiciel valide les maths, pas l'impression. Il n'est ni strictement plus dur ni strictement plus doux qu'une caméra de téléphone — il échoue différemment. Le flou synthétique ne comblera pas l'écart.
- Le contraste, c'est ce que la caméra attrape. Les aplats et les encodages trop fidèles l'affament ; achète de la marge avec la correction d'erreur plutôt que de vouloir déjouer l'optique.
- Garde les codes denses grands. Sous ~4 cm à 300 dpi, baisse la version.
- Profile le coût réel avant d'optimiser celui que tu imagines.
Le moteur est open source : sqrart/qart
sur Packagist (source), MIT. La
version hébergée, avec l'éditeur, l'export d'impression et les réglages éprouvés
sur le terrain, est sur sqr.art.