BLOG

Tech Teardown 038 | nibnib : un format d'image 4bpp pour six cycles

Yongliang Talks AI
AI
广告 · Advertisement

Quel est-il : Un format d’image minimaliste pour la décodage et extrêmement complexe pour la codage

Le 30 octobre 2026, Raph Levien (compte GitHub raphlinus) a publié un nouveau format d’image et une implémentation de référence en Rust, nommé nibnib. La description du repository est très concise : « Compression d’image à 4bpp avec une vitesse de décompression extrême ». Environ 21 minutes après la publication du repository, il a posté l’article sur Hacker News, recevant 88 points. À début novembre 2026, le repository comptait 58 stars, 2 forks, 0 open issues, aucun release, et 10 commits dans toute son histoire. Il est sous la licence Apache-2.0 ou MIT.

L’objectif de nibnib n’est pas le taux de compression ou la qualité d’image, mais une chose : réduire au maximum le coût de décodage. Il compresse l’image à 4 bits par pixel et, lors du décodage, il reconstitue deux pixels à partir d’un seul octet. Le cycle interne principal ne contient qu’une ligne de multiplication et addition. Le prix à payer est une asymétrie extrême de l’encodage ; la description du README indique clairement que l’encodage est plusieurs ordres de grandeur plus lent que la décodage.

Il convient d’en dire plus sur l’auteur, car ce projet porte ses années d’expérience. D’après sa page personnelle, Levien a travaillé dans l’équipe Google Fonts de décembre 2020 à octobre 2025, principalement sur la rendu des polices GPU et les recherches technologiques connexes ; il a également travaillé sur le traitement du texte et Rust dans l’équipe Fuchsia. Il est toujours actif dans la communauté Rust, participant aux projets de la série Xilem. Il a été le mainteneur de Ghostscript, et son thèse de doctorat portait sur la conception de courbes interactives. La police Inconsolata lui est également dédiée. Un autre détail est directement lié à nibnib : il a développé un algorithme de screening à demi-teintes de haute qualité pour les imprimantes à jet d’encre, appelé Even Toned Screening. Le screening à demi-teintes et la diffusion d’erreurs sont des technologies apparentées, et l’encodage de nibnib repose sur la diffusion d’erreurs Floyd-Steinberg. Ce n’est pas un changement de domaine, mais plutôt une retour au terrain familier pour un expert.

Pourquoi maintenant : Un microcontrôleur et un budget de 6 cycles d’horloge par pixel

La description du README explique l’origine de nibnib : il provient d’une expérience où l’on voulait savoir où se trouvaient les limites de la qualité d’image et des performances lorsque des vidéos DVI étaient écrites sur un microcontrôleur RP2350.

Le RP2350 est un microcontrôleur à bas coût qui permet d’émettre directement des signaux vidéo DVI, ce qui signifie que chaque pixel reçoit très peu de ressources de calcul. La pratique courante pour ce type d’équipement consiste à compresser les images avec une profondeur de bits basse et à utiliser une table de lookup pour la palette. Levien a fixé un budget clair : environ 6 cycles d’horloge par pixel. Que peut faire ces 6 cycles ? Prendre des pixels, chercher dans la table de lookup, écrire dans le buffer de ligne, rien de plus, sans marge.

Cette contrainte exclut presque tous les formats principaux. Pour décompresser un JPEG, il faut effectuer une décodage Huffman, une inverse quantification, une IDCT et une upsampling en couleur. Seule l’IDCT dépasse le budget de 6 cycles par pixel. Le algorithme DEFLATE utilisé pour PNG est un algorithme en série sur le flux binaire, qui ne permet pas non plus d’améliorer la qualité. Avec un budget de 6 cycles, la décodage ne peut adopter qu’une seule forme : une codage linéaire sans branches, sans décodage d’entropie, se limitant aux recherches dans les tables et à l’addition.

nibnib est la réponse à cette question. La réalisation de la décodage en temps réel sur le RP2350 est présente dans la démonstration image du repository vic-2350 de Levien. Le format et les matériels cibles sont créés ensemble. D’après l’histoire des commits, ce projet a été écrit entre le 5 et le 14 décembre 2025, avec des contributions continues pour la version initiale, la palette Gaussienne, l’erreur de RGB Weight, Floyd-Steinberg, la palette semi-structurée, l’équilibrage de l’erreur de ajustement. Il a ensuite été mis de côté pendant environ dix mois, avant qu’un changement de paragraphe ne soit apporté le 22 octobre 2026, et que le README ne soit finalisé et publié officiellement le 30 octobre. Un exemple typique de « dix jours d’expérimentation, puis publication un an plus tard ».

Un utilisateur dans la section des commentaires d’HN a expliqué davantage l’applicabilité de ce design asymétrique : dans la distribution de contenu en continu, une source centrale écrit une fois, et de nombreux terminaux lisent plusieurs fois. Le côté écrivant peut être lent, mais le côté lisant doit être rapide. Ce type de scénario profite naturellement de cette opportunité. Ce n’est pas seulement une technique spécifique aux microcontrôleurs, c’est une décision générale sur où placer la complexité.

Comment procéder : delta, multiplication par pixel et addition par ligne

Le cœur de nibnib réside en trois décisions de conception, chacune respectant le budget de 6 cycles.

Premièrement, les palettes stockent des deltas, pas des couleurs absolues. Les palettes traditionnelles de images indexées (mode palette GIF, PNG) stockent des couleurs absolues, chaque pixel ne pouvant prendre qu’une des 256 couleurs. Les transitions douces nécessitent un recours forcé au nombre de couleurs. Les palettes de nibnib stockent l’incrément par rapport au pixel précédent : après déchiffrer le pixel gauche, le pixel droit est égal à « pixel gauche + delta ». Ainsi, de petits incréments permettent de couvrir de grandes zones douces. Selon le README, cela permet d’améliorer la qualité visuelle par rapport aux images de palettes ordinaires, avec les zones douces bénéficiant le plus.

Deuxièmement, un octet décode deux pixels, obtenant ainsi 4bpp. La décodage progresse par paires de pixels : le pixel 16 bits déjà déchiffré est copié dans les deux parties d’un registre 32 bits, un octet est lu depuis le flux d’entrée, une delta 32 bits est obtenue à partir de la palette, puis additionnée. Les 16 derniers bits du résultat sont le pixel gauche, les 16 derniers sont le pixel droit, tous dans le format RGB565. Le noyau du cycle interne se trouve dans une seule ligne (le fragment en Rust du README) :

pix = ((pix >> 16) * 0x10001).wrapping_add(pal[inp as usize]);

Parmi ceux-ci, (pix >> 16) * 0x10001 effectue la « copie de pixels », appliquant une valeur de 16 bits aux deux moitiés hautes et basses de 32 bits ; wrapping_add est une addition enroulée qui ne craint pas l’excès. Le coût de chaque paire de pixels consiste en un chargement d’un octet, une recherche dans la table, un décalage, une multiplication, une addition, et deux sauvegardes, sans branches conditionnelles. La décodage est très fiable : toute implémentation de cette séquence de bytes produira une sortie identique bit par bit (le nombre d’instructions est ma interprétation du code source, pas un chiffre officiel).

Troisièmement, le format de fichier est tellement simple qu’il semble brut : 2 octets de largeur, 2 octets de hauteur, 256 entrées de palette de 32 bits (soit 1028 octets au début), suivies par des données ligne par ligne. Chaque ligne commence par deux pixels de base RGB565, occupant 4 octets, suivis de (width - 2) / 2 octets d’indexation, avec une distance entre les lignes qui est un multiple de 4 octets. Il n’y a pas de numéro de version, pas de checksum, et aucun métadonnée. Le README indique clairement : ce format n’est pas conçu pour l’archivage, mais plutôt pour être étroitement lié au décodeur.

L’encodage est un autre monde. Le README explique bien : « nibnib est défini par le processus de décodage » – le décodage est certain et simple, tandis que l’encodage est essentiellement une recherche pour trouver la meilleure correspondance avec l’image originale parmi toutes les possibilités d’encodage. La recherche a deux dimensions.

La première est le choix de la palette. La version actuelle de la palette a une structure fixe : 133 entrées cube et 123 entrées aléatoires. Les parties cube sont composées de combinaisons géométriques de 3×5×3 sur trois dimensions delta (1, 2, 4), afin de garantir que les valeurs proches de 0 et la plage complète soient accessibles sans excès. Le README précise que supprimer les parties cube entraînera de mauvais effets de bruit, et le dépôt fournit des images comparatives entre une palette purement aléatoire et une palette avec cube et aléatoire. La partie aléatoire utilise une échantillonnage gaussien pondéré, avec des dimensions logarithmiquement uniformes ; l’indicateur d’erreur est 0.3r² + 0.7g² + 0.1b², avec les poids du canal vert étant environ deux fois plus élevés que ceux du rouge et du bleu, correspondant à la sensibilité de l’œil pour le vert (ces constantes proviennent du code source encode.rs). Le README reconnaît également ouvertement que ce processus aléatoire a été conçu ad hoc sur des valeurs raisonnables, et que la palette ne s’adapte pas à l’image entrée pour l’instant. L’auteur indique clairement que c’est la direction d’amélioration la plus évidente.

Deuxièmement, il s’agit de choisir un codeword en utilisant la diffusion d’erreur Floyd-Steinberg. Pour chaque paire de pixels, l’encodage essaie toutes les 256 combinaisons de palettes, en sélectionnant celle qui présente la plus petite erreur de décodage. Ensuite, l’erreur est diffusée aux trois positions voisines à droite et dans la ligne suivante selon les poids classiques de Floyd-Steinberg (7/16, 1/16, 5/16, 3/16). L’objectif de la régression des pixels suivants n’est pas la valeur originale, mais la valeur originale plus l’erreur accumulée. La différence avec la diffusion d’erreur classique est que la méthode classique traite un pixel à la fois, tandis que nibnib produit deux pixels à la fois. Ainsi, on choisit le codeword qui minimise l’erreur combinée des deux pixels, et les poids de la somme des erreurs sont plus importants que la différence entre elles, en tenant compte du fait que l’œil humain est plus sensible à la faible fréquence spatiale. Un utilisateur sur HN (zahlman) a remis en question cette approche, affirmant qu’il s’agit simplement de filtrage delta avec quantification de palettes, mais il a également été surpris par ses résultats, car intuitivement, le filtrage delta accumule des erreurs. La raison pour laquelle l’erreur ne devient pas incontrôlable réside dans l’objectif de régression : à chaque étape, l’encodage intègre l’erreur accumulée dans l’objectif, permettant ainsi de résoudre continuellement le dérive (cette partie est ma déduction sur le code source).

La recherche gourmande a un coût connu. Le README indique que les artefacts actuels s’étendent sur le côté droit des bords nets. L’auteur propose des améliorations : changer l’ordre d’encodage de droite à gauche, ou même en forme de serpent (changement d’ordre dans chaque ligne), car l’étape d’addition est réversible dans la pratique ; ou effectuer une recherche plus approfondie, car lorsque les deux choix pour un pixel présentent des erreurs comparables, l’un peut rendre le choix suivant beaucoup plus simple. Ces améliorations ralentiront l’encodage, mais la vitesse de décodage restera constante. Les mots exacts du README sont : « Toutes les améliorations peuvent être réalisées sur le décodeur existant. »

## Expériences et données : Le repository ne fournit pas de chiffres, j’ai effectué une évaluation

Il n’y a aucun benchmark dans le repository de nibnib, ce qui a été directement souligné dans les commentaires sur HN : l’utilisateur augment_me pense qu’il est injuste de mettre en avant une vitesse de décodage extrême sans fournir des données comparatives avec JPEG ; un autre utilisateur, DoctorOetker, répond que l’objectif est précisément de cibler les plateformes où il n’est pas possible de décoderer JPEG en temps réel.

J’ai compilé la implémentation de référence sur mon propre ordinateur (Apple silicon Mac, Rust 1.96, build release) et j’ai exécuté l’image d’exemple fournie par le repository, flowers7.png (1000×750, RGB 8bit). Tous les chiffres suivants sont mes propres mesures, pas des statistiques officielles :

- La sortie codée est de 379,028 octets, conforme strictement à la formule de format 1028 + 750 × 504. nibnib est un codage de longueur fixe, sans émission d’entropie, et la taille de sortie est déterminée uniquement par la dimension de l’image.
- Le taux de compression effectif est de 4.043 bpp (les coûts liés au palette, aux pixels de chaque ligne et à l’alignement des lignes sont légèrement supérieurs à 4bpp nominal). Il est environ 3.96 fois plus élevé par rapport à la mémoire brute RGB565, et environ 5.94 fois par rapport à RGB888. Le fichier PNG dans le dépôt mesure 792,470 octets, mais il est sans perte, donc la compression entre les deux ne peut pas être comparée.
- Qualité d’image : La différence de PSNR entre la sortie décodée et l’image originale est d’environ 36.5 dB. Sur le plan subjectif, les bruitages dans les zones lisses sont les plus visibles, et les bords nets présentent une traînée sur le côté droit, ce qui correspond à la description dans le README.
- Temps de traitement : Le codage en entier par commande est d’environ 0.5 seconde pour l’utilisateur, et la décodage ainsi que la sortie du fichier PNG prend environ 0.12 seconde pour le système wall. Ces chiffres indiquent que les coûts liés à l’I/O du fichier PNG et à la commande CLI représentent la majeure partie du temps, et que le CPU de bureau n’est pas la plateforme cible, à titre de référence seulement.

Deux observations structurelles sont plus utiles que les chiffres. Premièrement, le codage de longueur fixe et l’alignement des lignes permettent de décompresser pendant le processus, sans attendre que le fichier complet soit entièrement chargé, ce qui correspond à la position du « décodeur en temps réel » dans vic-2350. Deuxièmement, tous les coûts liés au codage peuvent être payés hors ligne : une fois codé sur un ordinateur de bureau, le terminal ne paie que pour la décodage. Raphlinus a répondu à une critique sur HN (l’auteur soulignant que le code optimisé pour RP2350 ne convient presque certainement pas aux processeurs modernes à grande pipeline et large SIMD), et sa réponse était : « Absolutely. This is optimized for highly resource constrained devices, not modern machines. » Optimisé pour les appareils à faible ressources, non pour les machines modernes.

## Comparaison horizontal : JPEG, Amiga HAM et BCn

La comparaison avec JPEG permet de mieux comprendre la philosophie de conception de nibnib. Le README contient une comparaison positive : la transformation DCT de JPEG est mathématiquement réversible, la quantification est facile, et les vitesses de codage et de décodage sont à peu près égales en basse résolution, avec la recherche se limitant à des ajustements de paramètres pour améliorer légèrement la qualité d’image. nibnib est complètement inverse : la décodage est très simple, tandis que le codage est un problème de recherche plusieurs ordres de grandeur plus lent. En réalité, les deux sont deux solutions à la même question, c’est-à-dire où placer la complexité. JPEG place la complexité symétriquement aux deux extrémités, ce qui permet de garantir la universalité et l’écosystème ; nibnib pousse toute la complexité vers le côté du codage, ce qui permet d’obtenir le coût de décodage le plus bas sur le terminal. Le prix à payer est que la qualité d’image maximale avec le même taux de compression est inférieure à celle de JPEG : il n’a que 256 palettes de delta et 565 couleurs, sans transformations en domaine fréquentiel (cette estimation est ma propre conclusion, basée sur les spécifications du format, pas sur une comparaison réelle).

Dans la section des commentaires d’HN, on trouve un ancien parent : deux utilisateurs (wiml, vardump) ont souligné que le mode de affichage HAM de nibnib ressemble au mode Amiga. Selon les paramètres indiqués dans les commentaires, HAM utilise 6 bits d’entrée pour représenter 12 bits de couleurs à 4096 niveaux de gris sur la puce OCS, tandis que les puce AGA utilisent 8 bits pour représenter 262144 niveaux de gris. L’idée est la même : « Transmettre l’information du pixel précédent, avec seulement de pettes modifications ». Les artefacts en bandes horizontales sont également similaires. La différence réside dans le fait que HAM est un mode d’affichage, qui résout le problème des limites de nombre de couleurs ; nibnib est un format de stockage, qui résout le problème de la vitesse de décodage. De plus, delta agit sur les trois canaux RGB, travaillant deux pixels à la fois. Une technique utilisée sur les ordinateurs domestiques en 1985 est redevenue une option de conception quadrantième de siècle avec l’output DVI des microcontrôleurs, ce qui est très intéressant.

Un autre point important est la série de compression des textures GPU BCn. Augment_me a mentionné dans les commentaires que BCx fait essentiellement la même chose que le codebook/palette, et permet également l’interpolation entre deux extrêmes, offrant un nombre de couleurs plus élevé et une efficacité meilleure. nibnib ne fait pas d’interpolation ; 256 deltas de 32 bits suffisent, ce qui permet de supprimer toute logique d’interpolation dans le cycle de décodage interne. La qualité visuelle cède la place à la périodicité par pixel, le choix est clair.

Enfin, il convient de souligner la philosophie selon laquelle « le format est défini par le processus de décodage ». Ce n’est pas l’invention de nibnib ; les normes JPEG et PNG se basent également sur le côté décodage, laissant de la liberté au côté encodage. Mais nibnib a poussé cette idée à l’extrême : car le décodeur est suffisamment simple, il y a beaucoup de possibilités d’amélioration pour le encodeur, et tout est compatible avec le passé. Les concepts de palette adaptative, de recherche plus profonde et de scan en forme de serpent imaginés dans le README ne nécessitent pas de modifier un seul byte du décodeur existant. Pour un format expérimental qui n’a pas encore une écosystème, il est correct de fixer le décodeur dès le début.

## Comment l’utiliser et comment rester calme

L’utilisation est simple. Le répertoire est un projet standard Rust, avec seulement 3 fichiers et 351 lignes de code (main.rs, decode.rs, encode.rs). Après l’exécution `cargo build --release`, on obtient une utilité commande linéaire :

```bash
nibnib input.png output.nib -c    # Compression
nibnib output.nib decoded.png -d  # Décodage en PNG

L’entrée ne prend que le format PNG ; sans les options -c ou -d, il y a une conversion d’encodage immédiate, ce qui facilite la comparaison de la qualité visuelle. Pour l’output DVI RP2350, on peut utiliser l’image demo du répertoire vic-2350.

Le calme est plus important que l’utilisation ; je vais expliquer ma décision point par point :

Premièrement, sa maturité est limitée. 10 commits, projet solo, pas de sortie, pas de crate publié, aucune implémentation en C, aucun support de bibliothèque d’images. Il peut être utilisé comme exemple d’apprentissage ou comme matériel pour des projets embarqués personnels, mais à ce stade, il n’est pas conseillé d’le mettre dans un environnement de production, car son écosystème de PNG et JPEG n’est pas du même niveau.

Deuxièmement, son champ d’application est limité, comme l’explique clairement l’auteur. nibnib s’adresse aux scénarios où le budget de décodage est mesuré en cycles d’horloge et les matériaux peuvent être prétratés hors ligne : les ressources UI des appareils embarqués, les images pour consoles rétro, les matériaux d’affichage vidéo produits par les microcontrôleurs font partie de cette catégorie ; la sauvegarde des photos, la distribution des pages web, les matériaux d’impression ne le sont pas. Le README dit explicitement : ce format n’a pas été conçu pour l’archivage.

Troisièmement, le niveau de qualité visuelle n’est pas élevé, et l’encodage n’a pas encore atteint son limite. Avec 4bpp et une palette fixe non adaptative, cela signifie que les images colorées complexes seront inévitablement altérées par des bruitages et des traînées d’arrière-plan ; les deux principales améliorations suggérées par l’auteur (une palette adaptative, une recherche plus profonde) n’ont pas encore été réalisées. Le README est également honnête sur la situation actuelle : « What’s presented is a basic, working image compression technique. » Une version basique et fonctionnelle.

Quatrièmement, ne surestimez pas l’intérêt du format. 58 étoiles, 88 points, c’est un intérêt de niveau “idée intéressante”, pas de niveau “nouveau standard”. L’attention qu’il reçoit provient d’une approche de design propre et de l’expérience de l’auteur dans le domaine graphique, et non du fait que quelqu’un pourrait l’utiliser demain.

Mon conclusion en une phrase : la valeur de nibnib ne réside pas dans le fait qu’il propose un format d’image supplémentaire, mais dans le fait qu’il montre à quel point un design de format axé sur le coût de décodage peut être propre – en fixant d’abord le côté décodage, en transférant toute la complexité au côté encodage, permettant au format d’être défini par le processus de décodage. Pour les ingénieurs travaillant sur des graphiques embarqués, du matériel rétro ou des jouets d’affichage vidéo, ces 351 lignes de Rust méritent d’être lues ligne par ligne ; pour les autres lecteurs, se souvenir de cette philosophie de design suffit.

Sources de référence

Signé : Yongliang

广告 · Advertisement

Questions fréquentes

Quel est le but principal de nibnib ?

Le but principal de nibnib est de réduire au maximum le coût de décodage des images, en compressant l'image à 4 bits par pixel.

Comment fonctionne le décodage dans nibnib ?

Le décodage dans nibnib reconstitue deux pixels à partir d'un seul octet, utilisant une boucle interne de multiplication et addition.

Qui est l'auteur de nibnib ?

nibnib a été développé par Raph Levien, qui a travaillé sur des projets de rendu de polices GPU et des recherches technologiques pour Google Fonts, et est actif dans la communauté Rust.