Comment fonctionne le format d'images PNG ?

Je ne détaillerais pas le cheminement qui m'a amené à devoir lire la RFC 2083 qui décrit le format d'image PNG, mais maintenant que c'est fait, je trouvais intéressant d'écrire un petit article la dessus pour ceux que ça intéresse.

Je vais donc, sous forme de didactitiel pas à pas, tenter d'expliquer très globalement comment fonctionne le format, et comment créer une image avec.

Tout au long de l'article, j'utilise un interpréteur interactif Python pour automatiser certaines tâches très légèrement fastidieuses. Même si le but n'est pas de programmer un encodeur d'image, quelques rudiments de programmation seront tout de même utiles pour tout comprendre.

La matière première

L'image utilisée dans cet article est affichée ci-dessous (grossie 100x). J'ai pris une image relativement petite de 16x16px afin de pouvoir la manipuler plus facilement.

Mouton grossi 100x

Nous n'allons pas partir totalement de rien. J'ai préalablement créé une structure Python très simple pour décrire mon image. Comme tout le monde le sait, une image est composée de pixels. Un pixel, c'est l'élément le plus petit d'une image. Selon le type d'image (noir et blanc, couleur, avec transparence ou non), un pixel peut être décrit par plusieurs valeurs différentes, on parle de canaux.

Dans notre cas, l'image est en noir et blanc, mais nous allons coder les couleurs en RVB, et utiliser la transparence (RVBA donc, A pour Alpha, ce qui correspond au canal de la transparence). Nous avons donc 4 valeurs qui définissent notre pixel : le rouge, le vert, le bleu, et la transparence qui mélangés donnent une couleur, avec sa transparence. Toutes ces valeurs sont codées sur un octet, soit 8 bits. Cela permet 256 valeurs différentes, de 0 à 255. Le format PNG permet aussi de coder les canaux sur 2 octets pour obtenir de plus grandes nuances de couleurs, mais nous n'utiliserons pas cette fonctionnalité. Le tableau ci-dessous montre comment le mélange de couleurs permet de former le pixel, et comment l'assemblage de tous les pixels permet de former l'image.

RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA
RV
BA

Pour représenter ça en Python, j'ai donc pris chacune des quatres valeurs qui définissent un pixel pour les regrouper dans un tuple. Chacun des pixels d'une ligne est regroupé dans un tuple, et chaque tuple de ligne est regroupé dans un autre qui contient toutes les lignes de l'image.

Petit exemple, pour cette image de 2x2 pixels :

Image de 2x2 pixels

La représentation en Python sera :

( ( (0, 0, 0, 255), (255, 255, 255, 127) ), ( (255, 255, 255, 127), (0, 0, 0, 255) ) )

Vous pouvez télécharger le module Python qui contient la structure représentant mon image ou fabriquer votre propre image représentée sous cette forme.

Parlons PNG

La première chose que nous allons faire est de représenter notre image sous une autre forme plus "PNGesque". En fait, nous allons simplifier notre structures actuelle pour représenter une ligne sous forme de chaine d'octets. Chaque octet étant une des valeurs d'un pixel (R, V, B ou A) directement. La fonction Python chr() permet de transformer un entier en octet (ou caractère, ou encore byte).

Le code pour faire cette transformation est celui-ci :

>>> from data import image_brute >>> image = [''.join([''.join([chr(v) for v in px]) for px in ligne]) for ligne in image_brute]

Vous pouvez afficher image, ce qui vous donnera une liste de charabia incompréhensible correspondant à chaque ligne de notre image.

Le filtrage

Avant de compresser notre belle image avec algorithme bien connu de compression (inflate), nous devons l'optimiser un peu afin de maximiser la compression : on appelle cela le filtrage.

Il existe actuellement une seule méthode de filtrage qui répertorie cinq types de filtres différents. Une méthode de filtre est un genre de "pack" de filtres que l'on peut ensuite, au choix, utiliser sur chacune des lignes. Le choix d'un filtre pour une ligne peut se faire de différentes manières. La RFC n'impose pas la manière de faire cela (elle donne juste quelques indications), c'est donc à ceux qui implémentent un encodeur de déterminer eux même un algorithme pour cette tâche. Une des manières proposée par la RFC est de filtrer la ligne avec chacun des filtres successivement, puis de garder celui qui compresse le mieux. J'imagine que c'est l'algorithme utilisé la plupart du temps dans les encodeurs.

Vu que le but n'est pas de coder un encodeur, ni de rendre notre image la plus réduite possible, nous allons utiliser le filtre 0, celui qui consiste à ne rien faire. Il faut toutefois, au début de chaque ligne, ajouter le numéro du filtre utilisé :

>>> image = [chr(0)+ligne for ligne in image]

Compression !

Enfin, nous allons compresser notre image. L'algorithme de compression utilisé par le format PNG est inflate. Sans entrer dans les détails, inflate est un algorithme de compression sans perte qui combine l'algorithme LZ77 et le codage de Huffman. Ceux qui voudront plus de détails sur cette partie trouveront abondamment de la documentation sur leur moteur de recherche préféré.

Pour effectuer cette tâche nous n'allons donc pas implémenter l'algorithme, mais utiliser la fameuse zlib qui s'en chargera très bien. La zlib est utilisable en Python au travers du module zlib (logique) fournit en standard.

Nous allons dans un premier temps mettre tous les éléments de notre liste bout à bout puisque nous n'avons plus besoin d'effectuer des opérations par ligne de l'image :

>>> image = ''.join(image)

La compression en elle même est très simple avec la zlib :

>>> import zlib >>> image_compressee = zlib.compress(image)

Nous pouvons aussi constater que inflate compresse très bien notre image :

>>> print 'Compression à %.1f%%' % (100-len(image_compressee)*100.0/len(image))

La structure d'un fichier PNG

Nous avons maintenant notre image compressée avec l'algorithme qui va bien, mais le PNG ce n'est pas que ça, c'est aussi l'enrobage. Cet enrobage donne tout plein d'informations sur l'image, certaines sont indispensables comme la méthode de filtrage utilisée, le type de codage de la couleur etc. D'autres informations sont optionnelles, comme par exemple les commentaires que l'on pourrait définir sur une image.

Ces informations sont regroupées sous forme de bloc dans le jargon PNGien. Un bloc possède la forme suivante :

Longueur
(4 octets)
Type
(4 octets)
Données
(0+ octets)
CRC
(4 octets)

Le champ longueur indique tout simplement la longueur du champ donnée qui est lui même variable. Le champ type est un peu comme le nom du bloc. Il utilise des valeurs de la table ASCII, on trouve alors des blocs de type IDAT ou IHDR que nous détailleront plus tard. Les données dépendent du type de bloc (il peut ne pas y en avoir). Enfin le champ CRC est un controle d'intégrité du bloc et se calcule à partir des champs type et données.

Il existe naturellement plusieurs types de blocs PNG. Certains sont obligatoires comme les blocs IHDR (image header, il indique des informations caractéristiques de l'image comme ses dimensions), PLTE (palette, qui contient la palette de couleurs de l'image si l'image utilise le mode par couleur indéxées), IDAT (image data, qui contient l'image en elle même, compressée tel que nous l'avont fait plus haut), et IEND (marqueur de fin de l'image). L'ordonnancement de ces blocs est libre, à l'exception de IHDR qui doit se trouver au début de l'image, et de IEND qui doit se trouver à la fin de l'image. Cependant, certains blocs auxiliaires peuvent necessiter un ordonnancement particulier.

D'autres blocs, appelés blocs auxiliaires sont utilisables. La tableau ci-dessous récapitule tous les blocs auxiliaires définis par la spécification PNG.

Nom de type Description
bKGD Spécifie une couleur de fond par défaut pour l'affichage de l'image.
cHRM Spécifie les coordonnées x, y du modèle chromatique CIE 1931 pour les couleurs rouge, vert, et bleu primaires, ainsi que la référence du point blanc.
gAMA Spécifie le gamma de la caméra (réelle ou virtuelle) qui a produit l'image.
hIST Donne une fréquence approximative de l'utilisation de chaque couleur de la palette.
pHYs Spécifie la taille du pixel et son rapport dimensionnel.
sBIT Spécifie le nombre de bits significatifs d'origine.
tEXt Informations textuelles attachées à l'image.
tIME Date et heure de la dernière modification de l'image.
tRNS Spécifie que l'image utilise la transparence simple.
zTXt Informations textuelles compressées attachées à l'image.

Pour notre image, nous n'allons utiliser que les blocs absolument obligatoires :  IHDR, IDAT et IEND. PLTE étant inutile puisque nous n'utilisont pas le mode par couleurs indéxées.

Dernière chose, un fichier PNG possède une signature, une suite d'octets qui sert à la détection du format d'un fichier par les programmes. Cette signature doit positionner au tout début du fichier, juste avant le bloc IHDR. Elle est la suivante (en décimal) :

137 80 78 71 13 10 26 10

Le bloc IHDR

Commençont par le bloc qui sera le premier de notre fichier. Ce bloc donne donc les caractéristiques de l'image : sa largeur, sa hauteur, sont échantillonage, le type de couleurs utilisées, la méthode de compression, la méthode de filtrage, et enfin, la méthode d'entrelacement. Voici la forme de la partie données du bloc :

Largeur
(4 octets)
Hauteur
(4 octets)
Echantillonage
(1 octets)
Couleur
(1 octets)
Compression
(1 octets)
Filtrage
(1 octets)
Entrelacement
(1 octets)

Définissons chacune de ces valeurs avant de les coder.

Les dimensions de notre images sont de 16x16 pixels. Pour l'échantillonage, nous l'avons dit plus haut, nous utilisons des canaux de couleurs codés sur un octet, soit 8 bits. Le modèle de couleur à utiliser pour une image qui utilise le RVBA est le numéro 6. Je n'invente rien à ce niveau, tout est spécifié dans la RFC.

Pour la compression ainsi que le filtrage, à ce jour, seul une seule méthode existe, nous renseigneront donc 0. Nous n'entrelaçons pas, nous utiliseront donc aussi la valeur 0 pour ce champ.

Pour coder ces données en Python, nous utilisont le module struct qui sert justement à ça :

>>> import struct >>> IHDR = ['', '', '', ''] # Les 4 éléments d'un bloc >>> IHDR[1] = u'IHDR'.encode('ascii') >>> IHDR[2] = struct.pack('>IIBBBBB', 16, 16, 8, 6, 0, 0, 0) >>> IHDR[0] = struct.pack('>I', len(IHDR[2]))

N'oublions pas le CRC, nous allons à nouveau utiliser ce cher module zlib pour ça, puisqu'il contient une fonction tout prete cette tâche :

>>> IHDR[3] = struct.pack('>i', zlib.crc32(''.join(IHDR[1:3])))

Le bloc IDAT

La partie donnée du bloc contient simplement l'image sous la forme compressée que nous avont créé plus haut, la création de se bloc sera donc assez simple :

>>> IDAT = ['', '', '', ''] >>> IDAT[1] = u'IDAT'.encode('ascii') >>> IDAT[2] = image_compressee >>> IDAT[0] = struct.pack('>I', len(IDAT[2])) >>> IDAT[3] = struct.pack('>i', zlib.crc32(''.join(IDAT[1:3])))

Le bloc IEND

Encore plus simple que le bloc IDAT, le bloc IEND qui ne contient aucune donnée puisqu'il n'est qu'un marqueur de fin de fichier. Sa création se fait ainsi :

>>> IEND = ['', '', '', ''] >>> IEND[1] = u'IEND'.encode('ascii') >>> IEND[0] = struct.pack('>I', len(IEND[2])) >>> IEND[3] = struct.pack('>i', zlib.crc32(''.join(IEND[1:3])))

Signature du fichier

Comme expliqué plus haut, la signature du fichier sert à la détection du format par les programmes, sa création est des plus simples :

>>> signature = chr(137) + chr(80) + chr(78) + chr(71) + chr(13) + chr(10) + chr(26) + chr(10)

Et on colle tout ça :

Nous allons maintenant concaténer tous ces éléments dans l'ordre approprié, c'est à dire la signature en premier, suivie du bloc IHDR, suivi du bloc IDAT, et pour terminer du bloc IEND :

>>> png = signature + ''.join(IHDR) + ''.join(IDAT) + ''.join(IEND)

Finalement, nous enregistrons tout ceci dans un beau fichier :

>>> f = open('mon_beau.png', 'w') >>> f.write(png) >>> f.close()

Vous pouvez alors ouvrir le fichier avec votre lecteur préféré, il devrait normalement s'ouvrir sans encombre :-).

Vus : 907
Publié par Antoine Millet : 10