Le format PNG

Le format PNG est le format de compression sans perte d’images le plus utilisé aujourd’hui. Il est basé sur DEFLATE, auquel il ajoute plusieurs filtres de compression (il optimise les valeurs des pixels afin que le contenu soit plus facilement compressible). Il a été développé en 1996 par le W3C, l’organisme de standards qui gère d’autres normes du web comme le HTML, et a aussi été normalisé par l’ISO. Il gère notamment la transparence et, optionnellement, les palettes de couleurs.

Structure du fichier

Un fichier PNG commence par une signature, ou « magic », de 8 octets. Il s’agit d’une chaîne d’octets qu’on peut représenter comme \x89PNG\r\n\x1a\n en ASCII (avec des séquences d’échappement), ou 89 50 4E 47 0D 0A 1A 0A en hexadécimal.

Cette signature est pensée comme une trousse à outils pour aider à débugger les problèmes de transmission d’images, et que le fichier soit correctement reconnu dans tous les cas comme un fichier binaire. Elle contient successivement :

  • L’octet \x89, qui ne correspond pas à un caractère ASCII imprimable,
  • Les lettres ASCII « PNG »,
  • Un retour à la ligne DOS (composé des caractères \r\n, dits « CR LF »), afin de pouvoir détecter les éventuels fichiers rendus illisibles par un problème de transmission,
  • L’octet \x1A, qui signifiait la fin de fichier (« End of File » - EOF) sous DOS,
  • Un retour à la ligne UNIX (composé du seul caractère \n, dit « LF » pour la même raison que la présence du CR LF.

Passée la signature, il n’y a pas d’en-tête à proprement parler, le fichier PNG est entièrement composé de « chunks », des blocs d’informations dont certains sont obligatoires (comme les données de l’image), et certains sont répétables.

Chaque chunk est composé d’une taille de 4 octets, suivie d’un type de chunk, codé sur 4 lettres (dans le jargon des formats, on appelle cela un « FourCC », four-character code, on en trouve aussi dans d’autres formats comme MOV ou AVI), où les majuscules détermines certains attributs du chunk (est-il obligatoire, par exemple, ou peut-il être ignoré), suivi du contenu du chunk, suivi enfin d’un hash de vérification sur 32 bits (CRC-32).

Nom du champ

Taille du champ (octets)

Description

Taille du chunk

4

Taille du chunk en order big-endian. Celle-ci ne comprend que la taille du champ « données », pas celle des autres champs.

Type de chunk (FourCC)

4

Chaque lettre du type de chunk est en majuscule ou en majuscules. Les minuscules/majuscules attribuées à un type de chunk donné sont fixes et ne changent pas selon l’encodeur.

Elles ont la signification suivante :

  • 1ère lettre : si c’est une majuscule, l’interprétation du chunk est nécessaire au bon décodage de l’image, et le décodeur devrait afficher un message d’avertissement à l’utilisateur si le chunk concerné n’est pas connu.
  • 2ème lettre : si c’est une majuscule, le chunk est standard, et si c’est une minuscule il s’agit d’une extension propriétaire.
  • La 3ème lettre est toujours en majscule.
  • 4ème lettre : cette indication n’est pas utile aux décodeurs, mais seulement aux éditeurs d’images. Si c’est une majuscule, le champ ne devrait pas pouvoir être copié par l’éditeur dans un fichier modifié s’il n’est pas compris par l’éditeur, alors que si c’est une minuscule, il peut être copié sans être compris dans n’importe quel contexte.

En codage ASCII, la casse d’une lettre (majuscule ou minuscule) est codée par le 5ème bit de celle-ci (0 = majuscule, 1 = minuscule)

Contenu du chunk

Voir « taille du chunk »

Le contenu du chunk. Le chunk peut être vide. Il n’y a pas d’ajout automatique de padding.

Code de vérification d’intégrité

4

Le hash CRC-32 du chunk, calculé à partir des champs type et contenu du chunk, mais pas de sa taille (si on est arrivé ici, c’est qu’on a la bonne taille). Il est stocké en ordre big-endian.

L’ordre des chunks au sein du fichier varie, mais le champ IHDR (« Image header » - informations générales à propos du fichier) doit être placé en premier.

Les principaux types de chunk

Le chunk IHDR (Image Header)

Le chunk « IHDR » est placé tout au début de l’image, après la signature. Il est obligatoire et contient les informations suivantes (dont toutes sont en big-endian).

Taille Nom du champ Description
4 octets Width Largeur de l’image en pixels.
4 octets Height Hauteur de l’image en pixels.
1 octet Bit depth Nombre de bits utilisé par coder une couleur, OU une position dans la palette (PNG permet d’utiliser des palettes, qui sont des ensembles de couleurs pré-définies dans une image). Le plus souvent 8 (couleurs de 0 à 255), peut aussi être 1, 2, 4, 16.
1 octet Colour type Comment est colorisé chaque pixel : 0 = niveaux de gris ; 2 = RGB (rouge, bleu, vert), 3 = utilisation d’une palette, 4 = niveaux de gris + transparence ; 6 = RGB + transparence.
1 octet Compression method Une seule valeur possible : 0 pour DEFLATE. La taille de fenêtre glissante maximale est de 32 Ko (15 bits).
1 octet Filter method Une seule valeur possible : 0 pour les filtres par défaut de PNG. Avant d’appliquer DEFLATE, PNG applique des filtres qui permettront à l’image de « mieux » se compresser. C’est ce que nous verrons dans la suite de ce chapitre.
1 octet Interlace method Imaginons que vous ayez une connexion lente, mais que vous vouliez charger une grosse image. Très logiquement, l’image apparaîtra progressivement de haut en bas jusqu’à s’être chargée complètement. Ça, c’est le comportement par défaut de PNG, si ce champ est à « 0 ». Mais si ce champ est à « 1 », alors l’image aura été encodée d’une toute autre manière, qui fera que vous aurez d’abord une version en toute petite résolution de l’image (donc très floue) qui s’affichera, puis une un peu moins floue, etc. jusqu’à avoir l’image complète. Ce sera toujours la même image, sauf qu’elle aura été réorganisée (« entrelacée ») grâce à l’algorithme Adam7.
Les différents modes de couleur possibles avec PNG.
Les différents modes de couleur possibles avec PNG.
Illustration animée de l’algorithme Adam7 (source : Wikipédia, CC BY-SA, The Simpsons Contributor).

Le chunk PLTE (Palette)

Il devra être présent si le champ « Colour type » est à « 3 ». Il encodera alors les différentes couleurs qui peuvent être encodées dans l’image. Chaque couleur est codée sur trois octets (rouge, vert, bleu de 0 à 255). Le chunk « IDAT », à son tour, qui contient les données de l’image, référencera une position dans la palette pour chaque pixel, au lieu de contenir leurs couleurs respectives.

Chaque position de la palette qui sera contenue dans « IDAT » est alors codée sur un octet, ce qui fait qu’il peut au maximum y avoir 255 entrées dans la palette.

Ce chunk peut également être utilisé occasionnellement avec le champ « Colour type » à d’autres valeurs que « 3 », il pourra alors servir d’aide à l’affichage aux systèmes dont les capacités d’affichage des couleurs est limité, et ne sera pas impliqué dans le décodage. Cependant, cet usage est marginal.

Le chunk IDAT (Image Data)

Il contient les données de l’image. Autrement dit, il contient la couleur de chaque pixel à afficher, de gauche à droite puis de haut en bas. Sur une image avec couleurs et transparence, chaque pixel sera généalement codé sur quatre octets (rouge, vert, bleu, alpha - alpha marque la transparence, 0 est transparent et 255 est opaque). Sans transparence, sera trois octets ; en niveaux de gris, ce sera un octet (de 0 pour noir à 255 pour blanc), avec un palette, ce sera un octet (référence à l’entrée correspondance de la palette).

Le contenu des chunks IDAT est d’avoir pré-compressé avec les filtres que nous découvrirons ci-dessous, puis compressé avec le format ZLIB.

Plusieurs chunks « IDAT » peuvent se suivre (imaginez une image énorme dont on ne peut coder la taille sur 4 octets - c’est improbable - ou une image dont le contenu est généré progressivement par un serveur), il faudra alors fusionner leurs contenus respectifs. [TODO clarifier]

Le chunk IEND (Image End)

Il suit le chunk IDAT et est vide. Il ne contient pas de données.

Les autres chunks et l’ordre des chunks à respecter

Les chunks obligatoires doivent apparaître dans cet ordre : « IHDR » (un seul), « PLTE » (aucun ou un seul), IDAT (un ou plusieurs), IEND (un seul). Il existe aussi des chunks optionnels.

Certains chunks optionnels contiennet des métadonnées, c’est à dire des données qui n’influeront pas sur l’affichage de l’image :

Nom du chunk Ordre Description
tEXt Après « IHDR » Un commentaire à propos de l’image (par exemple : « Créé avec Paint.net »). Ce chunk contient un mot-clef (par exemple « Commentaire ») jusqu’à 79 caractères, suivi d’un ou dans les faits plusieurs octets nuls, suivis du commentaire en lui-même. Ce commentaire est encodé avec ISO-8859–1 (un encodage texte basé sur ASCII et utilisé en Europe dans les années 1990 et 2000, aujourd’hui on lui préfère UTF-8).
zTXt Après « IHDR » Variante compressée de tEXt. Au lieu du commentaire, vous aurez l’octet « 0 » pour spécifier qu’il s’agit de l’encodage ZLIB (comme dans l’en-tête de l’image), suivi d’un commentaire compressé avec ZLIB. Rarement utilisé.
iTXt Après « IHDR » Variante de tEXt, supportant à la fois la compression, l’encodage d’un commentaire en UTF-8 et la traduction d’un commentaire et d’un mot-clef dans la langue de votre choix. Rarement utilisé.
tIME Après « IHDR » Stocke la date de dernière modification d’une image. L’année est encodée sur deux octets, suivie du mois, du jour, de l’heure, de la minute et de a seconde, chacun sur un octet. Le temps est stocké en heure UTC, grosso modo le décalage horaire de nos amis anglais (la France ou la Belgique sont en UTC+1 à l’heure d’hiver et en UTC+2 à l’heure d’été). La seconde peut aller de 0 à 60 en cas de seconde intercalaire.

Les chunks optionnels cHRM, gAMA, iCCP, sBIT, sRGB permettent de définir des informations en plus sur la colorimétrie de l’image (notamment son profil ICC). Par exemple, je souhaite exporter mon image et l’utiliser pour produire un film et l’afficher sur un écran supportant beaucoup de nuances de couleurs, comment être sûr que le rendu soit le même entre nos deux appareils ? Ou entre un scanner qui a produit une image et un écran qui l’affichera ? Ces chunks, qui se placent après « IHDR » mais avant « PLTE » et « IDAT », ont d’abord été prévus pour les professionels de l’image.

D’autres chunks peuvent influencer l’affichage de l’image :

Nom du chunk Ordre Description
tRNS (Transparency) Après « IHDR » et « PLTE », avant « IDAT » Si une palette est utilisée, spécifie, pour chaque entrée de la palette (ou seulement les premières), une valeur de transparence codée sur un octet (de 0 à 255). Si on est en mode RGB sans transparence ou niveaux de gris, alors ce chunk permet de spécifier une couleur et une seule qui sera transformée en pixel totalement transpaent si elle est rencontrée (elle sera codée comme un pixel normal.
bKGD (Background) Après « IHDR » et « PLTE », avant « IDAT » Permet de spécifier une couleur de fond « par défaut » pour l’image : par exemple celle à afficher par défaut sous les pixels transparents, celle à proposer à l’utilisateur qui veut modifier l’image, ou celle à afficher autour de l’image s’il reste de la place.
hIST (Image histogram) Après « IHDR » et « PLTE », avant « IDAT » Permet de spécifier le nombre de fois où chaque couleur de la palette apparaît, lorsqu’une palette est utilisée. Potentiellement utile dans les environnements aux capacités de rendu limitées.
sPLT (Suggested palette) Après « IHDR », avant « IDAT » Permet de spécifier une palette de couleurs suggérée, pour les environnements aux capacités de rendu limitées. Commence par le nom de la palette, par exemple : « Palette pour Windows 3.1 », suivi d’un octet nul, suivi du nombre d’octets par couleur (« 1 » ou « 2 », codé sur 1 octet), suivi des couleurs R, G, B, A (Red, Blue, Green, Alpha) pour chaque entrée de la palette, chacune étant suivi du nombre d’occurrences de cette couleur codé sur deux octets.
pHYs (Physical pixels dimensions) Après « IHDR », avant « IDAT » Permet de suggérer le nombre de pixels par mètre sur lesquels représenter l’image (par exemple, si on souhaite l’imprimer). Le nombre de pixels par mètre horizontal et par mètre vertical sont chacun spécifiés sur 4 octets, suivi d’un octet d’unité de mesure valant « 1 » pour le mètre (ou « 0 » pour une unité inconnue).

Vous vous doutez bien de la plupart de ces chunks ne nous seront pas utiles tous les jours (notamment si nos images vont simplement servir à être affichées sur le web) ! Un fichier de format que l’on veut être répandu doit souvent prévoir beaucoup cas de figure possibles pour être adopté. Des outils comme pngcrush permettent d’optimiser un peu la taille d’une image PNG en supprimant les chunks inutiles, et en réglant la compression ZLIB à son maximum, et en s’assurant qu’une palette est utilisée si c’est intéressant.

Les filtres PNG

Une fois la décompression ZLIB effectuée, chaque ligne de notre fichier sera structurée de cette manière : un octet qui désigne le type de filtre, puis les valeurs de chaque pixel pour cette ligne de l’image (chaque pixel peut par exemple être encodé avec quatre octets : rouge, vert, bleu, et transparence), passées par ce filtre.

Les filtres PNG sont une forme de pré-compression qui permet d’optimiser la compression ZLIB.

Voici une illustration décrivant le fonctionnement des filtres PNG :

Dans les faits, il s’agit donc d’additioner les octets respectifs de plusieurs pixels entre eux, de façon à ne laisser que leurs différences plutôt que leurs valeurs :

Quand je parle d’ajouter deux octets entre eux, il s’agit de revenir à 0 s’ils dépassent 255 (du coup, un nombre qui demandera de faire baisser la valeur d’un pixel sera une valeur plus haute à ajouter).

Comment est-ce que le filtrage PNG aide DEFLATE à mieux compresser l’image ? Le fait d’utiliser des différences avec les pixels situés juste à côté, plutôt que les valeurs de ces pixels elles-mêmes, nous permettra d’avoir souvent des valeurs plus basses qui reviennent au sein du fichier, et donc d’optimiser le codage Huffman (avec les valeurs plus basses qui reviennent plus fréquemment, et qui seront donc plus courtes en bits). De plus, le filtrage PNG peut aussi révéler certains motifs répétables, sans forcément supprimer les répétitions « pures » qui existaient déjà (selon le cas de figure, elles seront probablement remplacées par des suites de « 0 », ou par les différences entre pixels idoines).

Un décodeur PNG en Python

Prenons ce smiley : :)

Il s’agit d’une image PNG hébergée à l’adresse suivante : https://zestedesavoir.com/static/smileys/smile.png

Nous allons essayer de la télécharger, de la décoder et de l’afficher dans notre terminal. Comment ? Notre terminal n’affiche pas les images !

Eh bien nous allons utiliser une fonctionnalité qui est supportée par la plupart des terminaux, et qui permet de colorer le texte affiché à l’utilisateur : https://en.wikipedia.org/wiki/ANSI_escape_code#Colors

Vous l’aurez compris, chaque pixel sera affiché comme un caractère coloré.

Si nous voulons afficher du texte en rose (rouge = 255, vert = 93, bleu = 204), il faudra afficher successivement ce qui suit :

  • Le caractère d’échappement ESC (\x1B)
  • Le caractère [ qui préfixe la plupart des codes d’échappement de terminal
  • 48; qui indique que nous voulons changer la couleur d’arrière-plan
  • 2; qui indique que nous voulous une couleur codée sur 24 bits (3 octets de 0 à 255)
  • 255;93;204 qui est notre couleur RGB représentée en décimal
  • m comme caractère de séparation
  • Le texte à colorer

Cela donne \x1B[48;2;255;93;204mBONJOUR. Pour retrouver la couleur initial du terminal, faîtes \x1B[0m. Attention, cela fonctionnera avec les terminaux récents sous Linux, mais pas forcément sous Windows (il semblerait que ce soit possible avec les versions récentes).

Testez si ça fonctionne avec une commande comme echo -e '\x1B[48;2;255;93;204mBONJOUR\x1B[0m'. Si ça ne fonctionne pas, il se peut que vous ayez à utiliser une des couleurs pré-définies du terminal, comme \x1B[32m.

Pourquoi est-ce aussi compliqué ? Dans les années 1980, quand les gens achetaient un écran d’ordinateur, ils achetaient d’abord un composant capable de recevoir du texte d’une unité centrale, tout comme d’autre chose sur un port série et de l’afficher. Le plus populaire d’entre eux, le VT100, a fixé les bases de ces codes spéciaux. Ils ont ensuite évolué maintes fois.

Trève de bavardages. Commençons par écrire de quoi décoder les champs venant au début de notre fichier PNG. Nous avons de la chance, PNG est un format basé sur les octets plutôt que basé sur les bits, ce qui est normal pour un format d’image et différent des formats de compression que nous avons vu auparavant. Ce qui veut dire que Python nous propose des outils standards pour pouvoir de lire (pas besoin de créer notre classe LecteurDeBits ici).

Nous utiliserons plus précisément le module ctypes, qui nous permet entre d’autres choses facilement de définir nos structures d’octets comme des classes, d’une manière qui soit portable, et de lire ou d’écrire ces structures depuis un fichier (ou depuis un flux d’octets BytesIO).

Nous utiliserons aussi le module enum afin de définir proprement dans des classes les valeurs constantes (telles que 2 pour une image en RGB ou 3 pour une image avec palette).

Voici les déclarations ctypes et enum que nous utiliserons :

from ctypes import BigEndianStructure, c_char, c_uint8, c_uint32
from enum import IntEnum

# D'après https://www.w3.org/TR/PNG/#5PNG-file-signature

class PNGHeader(BigEndianStructure):
    
    _fields_ = [
        ('magic', c_char * 8) # La magic PNG : b'\x89PNG\r\n\x1a\n'
        
        # ^ « c_char * 8 » indique une chaîne de 8 octets
    ]
    
    _pack_ = True

# D'après https://www.w3.org/TR/PNG/#5Chunk-layout

class PNGChunkHeader(BigEndianStructure):

    _fields_ = [
        ('length', c_uint32),
        ('chunk_type', c_char * 4)
    ]
    
    _pack_ = True

class PNGChunkFooter(BigEndianStructure):

    _fields_ = [
        ('crc', c_uint32),
    ]
    
    _pack_ = True

# D'après https://www.w3.org/TR/PNG/#11IHDR

class PNGIHDRChunkData(BigEndianStructure):

    _fields_ = [
        ('width', c_uint32), # "uint32" signifie "unsigned 32-bits integer", dont nombre codé sur 4 octets et forcément positif
        ('height', c_uint32),
        ('bit_depth', c_uint8),
        ('colour_type', c_uint8),
        ('compression_method', c_uint8),
        ('filter_method', c_uint8),
        ('interlace_method', c_uint8)
    ]
    
    _pack_ = True

class PNGColourType(IntEnum):

    greyscale = 0
    truecolour = 2 # RGB
    indexed_colour = 3 # Palette
    greyscale_with_alpha = 4
    truecolour_with_alpha = 6 # RGBA

class PNGCompressionMethod(IntEnum):

    deflate = 0 # ZLIB avec une taille de fenêtre de 32 Ko

class PNGFilterMethod(IntEnum):

    standard = 0

class PNGInterlaceMethod(IntEnum):

    none = 0
    adam7 = 1

# D'après https://www.w3.org/TR/PNG/#9Filter-types

class PNGFilterType:
    
    none = 0
    sub = 1
    up = 2
    average = 3
    paeth = 4

À quoi sert _pack_ = True dans chaque classe ctypes ? Cela indique au système de ne pas aligner nos champs (sur 4 octets si c’est un système 32-bits ou sur 8 octets si c’est un système 64-bits) comme il le ferait normalement. Ainsi, notre fichier PNG se décodera bien et notre programme sera portable d’un système à un autre.

Passons à la lecture de notre fichier PNG.

Nous allons utiliser le module argparse pour demander proprement le chemin d’une image à afficher pour l’utilisateur.

from argparse import ArgumentParser

args = ArgumentParser(description = "Affiche une image PNG de petite taille " +
    "dans le terminal, en utilisant les caractères d'échappement ANSI.")

args.add_argument('fichier_entree', help = "Chemin de l'image à décoder sur le disque.")

args = args.parse_args()

with open(args.fichier_entree, 'rb') as objet_fichier:
    mon_decodeur_png(objet_fichier.read())

Voici le code de notre décodeur :

def mon_decodeur_png(entree : bytes):
    
    lecteur = BytesIO(entree)
    
    # Lecture de l'en-tête PNG
    
    png_header = PNGHeader()
    lecteur.readinto(png_header)
    
    assert png_header.magic == b'\x89PNG\r\n\x1a\n'
    
    # Lecture de chaque chunk
    
    donnees_image = b''
    image_header : PNGIHDRChunkData = None
    
    palette_rgba : List[Tuple[int, int, int]] = []
    
    couleur_transparente : Optional[bytes] = None
    
    while True:
        
        chunk_header = PNGChunkHeader()
        lecteur.readinto(chunk_header)
        
        if not chunk_header.chunk_type:
            break # Fin du fichier atteinte
        
        chunk_data = lecteur.read(chunk_header.length)
        
        if chunk_header.chunk_type == b'IHDR':
            
            image_header = PNGIHDRChunkData.from_buffer_copy(chunk_data)
        
        elif chunk_header.chunk_type == b'PLTE':
            
            for position in range(0, len(chunk_data), 3):
                r, g, b = chunk_data[position:position + 3]
                
                palette_rgba.append((r, g, b, 255))
        
        elif chunk_header.chunk_type == b'tRNS':
        
            if image_header.colour_type == PNGColourType.indexed_colour:
                
                for position in range(len(chunk_data)):
                    
                    r, g, b, _ = palette_rgba[position]
                    
                    palette_rgba[position] = (r, g, b, chunk_data[position])
            
            else:
                
                couleur_transparente = chunk_data
            
        elif chunk_header.chunk_type == b'IDAT':
            
            donnees_image += chunk_data
            
        elif chunk_header.chunk_type == b'IEND':
            
            pass
            
        
        chunk_footer = PNGChunkFooter()
        lecteur.readinto(chunk_footer)
        
        assert crc32(chunk_header.chunk_type + chunk_data) & 0xffffffff == chunk_footer.crc
    
    # Traitement des données : on passe sur chaque ligne, puis sur chaque
    # colonne
    
    if image_header.bit_depth != 8:
        
        raise ValueError("Ce programme ne supporte que les images d'une profondeur de 8 bits")
    
    lecteur = BytesIO(decompress(donnees_image))
    
    octets_par_pixel = {
        PNGColourType.truecolour_with_alpha: 4,
        PNGColourType.indexed_colour: 1,
        PNGColourType.truecolour: 3,
        PNGColourType.greyscale: 1,
        PNGColourType.greyscale_with_alpha: 2
    }[image_header.colour_type]
    
    # Si des filtres font référence au pixel « au-dessus » ou « à gauche », ou
    # « au-dessus à gauche », alors qu'ils n'ont pas encore été lus, le
    # programme devra considérer qu'il s'agit d'un zéro.
    
    pixels_apres_filtrage : bytes = b'\x00' * (octets_par_pixel * (image_header.width + 1))
    
    for position_y in range(image_header.height):
        
        ligne_sortie = ''
        type_de_filtre = lecteur.read(1)[0]
        
        
        for position_x in range(image_header.width):
        
            octets_pixel = lecteur.read(octets_par_pixel)
            
            for octet in octets_pixel:
                    
                base_octet = 0
                
                if type_de_filtre == PNGFilterType.none:
                    pass
                
                elif type_de_filtre == PNGFilterType.sub:
                    base_octet = pixels_apres_filtrage[-octets_par_pixel] if position_x else 0
                    
                elif type_de_filtre == PNGFilterType.up:
                    base_octet = pixels_apres_filtrage[-image_header.width * octets_par_pixel] if position_y else 0
                    
                elif type_de_filtre == PNGFilterType.average:
                    sub_octet = pixels_apres_filtrage[-octets_par_pixel] if position_x else 0
                    
                    up_octet = pixels_apres_filtrage[-image_header.width * octets_par_pixel] if position_y else 0

                    base_octet = (sub_octet + up_octet) // 2
                    
                elif type_de_filtre == PNGFilterType.paeth:
                    sub_octet = pixels_apres_filtrage[-octets_par_pixel] if position_x else 0
                    
                    up_octet = pixels_apres_filtrage[-image_header.width * octets_par_pixel] if position_y else 0

                    sub_up_octet = pixels_apres_filtrage[-(image_header.width + 1) * octets_par_pixel] if position_x and position_y else 0

                    
                    def paeth(sub, up, sub_up):
                        moyenne = sub + up - sub_up
                        
                        ecart_sub = abs(moyenne - sub)
                        ecart_up = abs(moyenne - up)
                        ecart_sub_up = abs(moyenne - sub_up)
                        
                        if ecart_sub <= ecart_up and ecart_sub <= ecart_sub_up:
                            return sub
                        elif ecart_up <= ecart_sub_up:
                            return up
                        else:
                            return sub_up
                    
                    base_octet = paeth(sub_octet, up_octet, sub_up_octet)
                    
                octet += base_octet
                octet &= 0xff
                
                
                pixels_apres_filtrage += bytes([octet])
                    
                    
            if pixels_apres_filtrage[-octets_par_pixel:] == couleur_transparente:
                r = g = b = a = 0
                
            elif image_header.colour_type == PNGColourType.indexed_colour:
                r, g, b, a = palette_rgba[pixels_apres_filtrage[-1]]
            
            elif image_header.colour_type == PNGColourType.truecolour_with_alpha:
                r, g, b, a = pixels_apres_filtrage[-4:]
                
            elif image_header.colour_type == PNGColourType.truecolour:
                (r, g, b), a = pixels_apres_filtrage[-3:], 255
            
            elif image_header.colour_type == PNGColourType.greyscale:
                r = g = b = pixels_apres_filtrage[-1]
                a = 255
            
            elif image_header.colour_type == PNGColourType.greyscale_with_alpha:
                r = g = b = pixels_apres_filtrage[-2]
                a = pixels_apres_filtrage[-1]
                
                
            if a > 0: # Afficher un pixel de couleur
                
                # On considère qu'on est sur fond blanc : on va gérer les
                # pixels semi-transparents en les faisant virer graduellement
                # vers le blanc (255)
                
                r = 255 - ((255 - r) * (a / 255))
                g = 255 - ((255 - g) * (a / 255))
                b = 255 - ((255 - b) * (a / 255))
                
                ligne_sortie += '\x1B[48;2;%d;%d;%dm' % (r, g, b) + '  ' + '\x1B[0m'
                
            else: # Afficher un pixel transparent
                ligne_sortie += '  '
                
        
        print(ligne_sortie)

Voici le code en entier :

#!/usr/bin/python3
#-*- encoding: Utf-8 -*-

from ctypes import BigEndianStructure, c_char, c_uint8, c_uint32
from typing import List, Tuple, Set, Dict, Union, Optional
from argparse import ArgumentParser
from zlib import decompress
from binascii import crc32
from enum import IntEnum
from io import BytesIO

# D'après https://www.w3.org/TR/PNG/#5PNG-file-signature

class PNGHeader(BigEndianStructure):
    
    _fields_ = [
        ('magic', c_char * 8) # La magic PNG : b'\x89PNG\r\n\x1a\n'
        
        # ^ « c_char * 8 » indique une chaîne de 8 octets
    ]
    
    _pack_ = True

# D'après https://www.w3.org/TR/PNG/#5Chunk-layout

class PNGChunkHeader(BigEndianStructure):

    _fields_ = [
        ('length', c_uint32),
        ('chunk_type', c_char * 4)
    ]
    
    _pack_ = True

class PNGChunkFooter(BigEndianStructure):

    _fields_ = [
        ('crc', c_uint32),
    ]
    
    _pack_ = True

# D'après https://www.w3.org/TR/PNG/#11IHDR

class PNGIHDRChunkData(BigEndianStructure):

    _fields_ = [
        ('width', c_uint32), # "uint32" signifie "unsigned 32-bits integer", dont nombre codé sur 4 octets et forcément positif
        ('height', c_uint32),
        ('bit_depth', c_uint8),
        ('colour_type', c_uint8),
        ('compression_method', c_uint8),
        ('filter_method', c_uint8),
        ('interlace_method', c_uint8)
    ]
    
    _pack_ = True

class PNGColourType(IntEnum):

    greyscale = 0
    truecolour = 2 # RGB
    indexed_colour = 3 # Palette
    greyscale_with_alpha = 4
    truecolour_with_alpha = 6 # RGBA

class PNGCompressionMethod(IntEnum):

    deflate = 0 # ZLIB avec une taille de fenêtre de 32 Ko

class PNGFilterMethod(IntEnum):

    standard = 0

class PNGInterlaceMethod(IntEnum):

    none = 0
    adam7 = 1

# D'après https://www.w3.org/TR/PNG/#9Filter-types

class PNGFilterType:
    
    none = 0
    sub = 1
    up = 2
    average = 3
    paeth = 4


def mon_decodeur_png(entree : bytes):
    
    lecteur = BytesIO(entree)
    
    # Lecture de l'en-tête PNG
    
    png_header = PNGHeader()
    lecteur.readinto(png_header)
    
    assert png_header.magic == b'\x89PNG\r\n\x1a\n'
    
    # Lecture de chaque chunk
    
    donnees_image = b''
    image_header : PNGIHDRChunkData = None
    
    palette_rgba : List[Tuple[int, int, int]] = []
    
    couleur_transparente : Optional[bytes] = None
    
    while True:
        
        chunk_header = PNGChunkHeader()
        lecteur.readinto(chunk_header)
        
        if not chunk_header.chunk_type:
            break # Fin du fichier atteinte
        
        chunk_data = lecteur.read(chunk_header.length)
        
        if chunk_header.chunk_type == b'IHDR':
            
            image_header = PNGIHDRChunkData.from_buffer_copy(chunk_data)
        
        elif chunk_header.chunk_type == b'PLTE':
            
            for position in range(0, len(chunk_data), 3):
                r, g, b = chunk_data[position:position + 3]
                
                palette_rgba.append((r, g, b, 255))
        
        elif chunk_header.chunk_type == b'tRNS':
        
            if image_header.colour_type == PNGColourType.indexed_colour:
                
                for position in range(len(chunk_data)):
                    
                    r, g, b, _ = palette_rgba[position]
                    
                    palette_rgba[position] = (r, g, b, chunk_data[position])
            
            else:
                
                couleur_transparente = chunk_data
            
        elif chunk_header.chunk_type == b'IDAT':
            
            donnees_image += chunk_data
            
        elif chunk_header.chunk_type == b'IEND':
            
            pass
            
        
        chunk_footer = PNGChunkFooter()
        lecteur.readinto(chunk_footer)
        
        assert crc32(chunk_header.chunk_type + chunk_data) & 0xffffffff == chunk_footer.crc
    
    # Traitement des données : on passe sur chaque ligne, puis sur chaque
    # colonne
    
    if image_header.bit_depth != 8:
        
        raise ValueError("Ce programme ne supporte que les images d'une profondeur de 8 bits")
    
    lecteur = BytesIO(decompress(donnees_image))
    
    octets_par_pixel = {
        PNGColourType.truecolour_with_alpha: 4,
        PNGColourType.indexed_colour: 1,
        PNGColourType.truecolour: 3,
        PNGColourType.greyscale: 1,
        PNGColourType.greyscale_with_alpha: 2
    }[image_header.colour_type]
    
    # Si des filtres font référence au pixel « au-dessus » ou « à gauche », ou
    # « au-dessus à gauche », alors qu'ils n'ont pas encore été lus, le
    # programme devra considérer qu'il s'agit d'un zéro.
    
    pixels_apres_filtrage : bytes = b'\x00' * (octets_par_pixel * (image_header.width + 1))
    
    for position_y in range(image_header.height):
        
        ligne_sortie = ''
        type_de_filtre = lecteur.read(1)[0]
        
        
        for position_x in range(image_header.width):
        
            octets_pixel = lecteur.read(octets_par_pixel)
            
            for octet in octets_pixel:
                    
                base_octet = 0
                
                if type_de_filtre == PNGFilterType.none:
                    pass
                
                elif type_de_filtre == PNGFilterType.sub:
                    base_octet = pixels_apres_filtrage[-octets_par_pixel] if position_x else 0
                    
                elif type_de_filtre == PNGFilterType.up:
                    base_octet = pixels_apres_filtrage[-image_header.width * octets_par_pixel] if position_y else 0
                    
                elif type_de_filtre == PNGFilterType.average:
                    sub_octet = pixels_apres_filtrage[-octets_par_pixel] if position_x else 0
                    
                    up_octet = pixels_apres_filtrage[-image_header.width * octets_par_pixel] if position_y else 0

                    base_octet = (sub_octet + up_octet) // 2
                    
                elif type_de_filtre == PNGFilterType.paeth:
                    sub_octet = pixels_apres_filtrage[-octets_par_pixel] if position_x else 0
                    
                    up_octet = pixels_apres_filtrage[-image_header.width * octets_par_pixel] if position_y else 0

                    sub_up_octet = pixels_apres_filtrage[-(image_header.width + 1) * octets_par_pixel] if position_x and position_y else 0

                    
                    def paeth(sub, up, sub_up):
                        moyenne = sub + up - sub_up
                        
                        ecart_sub = abs(moyenne - sub)
                        ecart_up = abs(moyenne - up)
                        ecart_sub_up = abs(moyenne - sub_up)
                        
                        if ecart_sub <= ecart_up and ecart_sub <= ecart_sub_up:
                            return sub
                        elif ecart_up <= ecart_sub_up:
                            return up
                        else:
                            return sub_up
                    
                    base_octet = paeth(sub_octet, up_octet, sub_up_octet)
                    
                octet += base_octet
                octet &= 0xff
                
                
                pixels_apres_filtrage += bytes([octet])
                    
                    
            if pixels_apres_filtrage[-octets_par_pixel:] == couleur_transparente:
                r = g = b = a = 0
                
            elif image_header.colour_type == PNGColourType.indexed_colour:
                r, g, b, a = palette_rgba[pixels_apres_filtrage[-1]]
            
            elif image_header.colour_type == PNGColourType.truecolour_with_alpha:
                r, g, b, a = pixels_apres_filtrage[-4:]
                
            elif image_header.colour_type == PNGColourType.truecolour:
                (r, g, b), a = pixels_apres_filtrage[-3:], 255
            
            elif image_header.colour_type == PNGColourType.greyscale:
                r = g = b = pixels_apres_filtrage[-1]
                a = 255
            
            elif image_header.colour_type == PNGColourType.greyscale_with_alpha:
                r = g = b = pixels_apres_filtrage[-2]
                a = pixels_apres_filtrage[-1]
                
                
            if a > 0: # Afficher un pixel de couleur
                
                # On considère qu'on est sur fond blanc : on va gérer les
                # pixels semi-transparents en les faisant virer graduellement
                # vers le blanc (255)
                
                r = 255 - ((255 - r) * (a / 255))
                g = 255 - ((255 - g) * (a / 255))
                b = 255 - ((255 - b) * (a / 255))
                
                ligne_sortie += '\x1B[48;2;%d;%d;%dm' % (r, g, b) + '  ' + '\x1B[0m'
                
            else: # Afficher un pixel transparent
                ligne_sortie += '  '
                
        
        print(ligne_sortie)
            
    
    
# Prendre un chemin de la part de l'utilisateur en entrée.

args = ArgumentParser(description = "Affiche une image PNG de petite taille " +
    "dans le terminal, en utilisant les caractères d'échappement ANSI.")

args.add_argument('fichier_entree', help = "Chemin de l'image à décoder sur le disque.")

args = args.parse_args()

with open(args.fichier_entree, 'rb') as objet_fichier:
    mon_decodeur_png(objet_fichier.read())


Ici, on utilise l’implémentation de ZLIB fournie par Python, mais on pourrait aussi utiliser celle qu’on a écrite auparavant.

On le lance sur notre image, et ça donne :


Liens utiles :