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 :
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. |


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-plan2;
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écimalm
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 :