Optimisation efficace des PNG en Python avec Oxipng
Utilisez pyoxipng pour compresser un PNG en mémoire, puis enregistrez-le uniquement si la destination
n’existe pas encore. Ce guide vous fournit un script pour des fichiers individuels et de petits
lots, avec des tailles mesurées, des codes de sortie non nuls en cas d’échec et une vérification
distincte que les pixels décodés restent inchangés.
OptiPNG face à Oxipng
OptiPNG et
Oxipng sont deux optimiseurs PNG distincts. Le paquet Python utilisé ici,
pyoxipng, encapsule la bibliothèque Rust d’Oxipng et s’importe sous le nom
oxipng. Vous n’avez besoin ni d’un exécutable OptiPNG ni d’un sous-processus pour l’appeler.
Décider ce qu’il faut préserver
L’optimisation sans perte peut modifier la compression, la palette ou le type de couleur d’un PNG tout en préservant ses pixels décodés. Elle ne promet aucun pourcentage de réduction particulier. Un fichier déjà compact peut ne pas diminuer du tout.
L’exemple conserve les fichiers sources intacts, refuse les destinations existantes et ne demande
pas la suppression des métadonnées. Il laisse aussi inchangées les couleurs des pixels transparents
avec optimize_alpha=False. Utilisez-le pour des ressources PNG statiques locales, dans des répertoires que
vous contrôlez. La vérification des pixels ci-dessous couvre les entrées RGB et RGBA 8 bits ; elle
ne valide ni l’animation, ni la précision 16 bits, ni le rendu avec gestion des couleurs.
Prérequis
Les commandes ci-dessous utilisent Bash et Python 3.12 avec venv et pip. Elles ont été testées
sous Linux avec Python 3.12.13 et pyoxipng 9.1.1. Partez d’un répertoire où png-demo n’existe pas
encore :
mkdir png-demo &&
cd png-demo &&
python3.12 -m venv .venv &&
.venv/bin/python -m pip install --only-binary=:all: pyoxipng==9.1.1 Pillow==12.3.0
La chaîne && interrompt l’installation si une étape échoue, y compris si le répertoire png-demo existe
déjà. L’option --only-binary exige une wheel compatible au lieu de lancer une compilation Rust depuis les
sources. PyPI liste les wheels disponibles. Pillow sert
uniquement à la vérification des pixels, pas à l’optimisation. Exécutez les commandes restantes
depuis png-demo.
Optimiser un seul PNG
Enregistrez le code suivant sous optimize_png.py. Il transmet les octets PNG encodés à
optimize_from_memory
et ne reconstruit donc pas l’image à partir d’un tableau de pixels Pillow ou NumPy.
import argparse
from pathlib import Path
import sys
from time import perf_counter
import oxipng
def optimize_bytes(data: bytes, level: int) -> bytes:
candidate = oxipng.optimize_from_memory(
data,
level=level,
strip=oxipng.StripChunks.none(),
optimize_alpha=False,
)
return candidate if len(candidate) < len(data) else data
def optimize_file(source: Path, destination: Path, level: int) -> None:
start = perf_counter()
original = source.read_bytes()
optimized = optimize_bytes(original, level)
# Exclusive creation fails even for a dangling destination symlink.
output = destination.open("xb")
try:
with output:
output.write(optimized)
except OSError:
# Only remove the file this invocation successfully created.
destination.unlink()
raise
saved = len(original) - len(optimized)
print(
f"{source.name}: {len(original)} -> {len(optimized)} bytes; "
f"saved {saved} bytes; {perf_counter() - start:.3f} s"
)
def main() -> int:
parser = argparse.ArgumentParser(description="Optimize PNGs without overwriting outputs")
parser.add_argument("source", type=Path, help="PNG file or directory")
parser.add_argument("destination", type=Path, help="New PNG file or output directory")
parser.add_argument("--level", type=int, choices=range(7), default=2)
args = parser.parse_args()
try:
source = args.source.resolve(strict=True)
if source.is_dir():
if source == args.destination.resolve():
raise ValueError("Source and output directories must differ")
files = sorted(
path for path in source.iterdir()
if path.suffix.lower() == ".png" and not path.is_symlink() and path.is_file()
)
if not files:
raise ValueError("No PNG files found")
args.destination.mkdir(exist_ok=True)
pairs = [(path, args.destination / path.name) for path in files]
else:
pairs = [(source, args.destination)]
except (OSError, ValueError) as error:
parser.exit(1, f"Error: {error}\n")
failed = False
for input_path, output_path in pairs:
try:
optimize_file(input_path, output_path, args.level)
except (OSError, oxipng.PngError) as error:
print(f"Failed {input_path.name}: {type(error).__name__}: {error}", file=sys.stderr)
failed = True
return int(failed)
if __name__ == "__main__":
sys.exit(main())
Placez un PNG nommé input.png à côté du script, puis exécutez :
.venv/bin/python optimize_png.py input.png output.png
En cas de succès, il affiche le nombre d’octets en entrée et en sortie, les octets économisés et les
secondes écoulées, E/S de fichier comprises. Un résultat de zéro octet économisé est valide : le
script écrit une copie de l’original lorsque le candidat n’est pas plus petit. Relancer la même
commande signale FileExistsError, se termine avec le statut 1 et laisse output.png intact. Utiliser l’entrée
elle-même comme destination échoue également, sans la modifier.
Le mode "xb" de Python prend la décision de ne pas écraser au
moment de la création du fichier. Une entrée corrompue ou manquante échoue avant ce stade. Si
l’écriture ou la fermeture du fichier nouvellement créé lève OSError, le script tente de supprimer
cette sortie partielle. Il s’agit d’un script local, pas d’un mécanisme de publication atomique : un
arrêt forcé peut laisser un fichier partiel, et aucun autre processus ne devrait utiliser la
destination avant la réussite.
Optimiser un répertoire
Placez vos PNG dans images_source, puis utilisez le même script :
.venv/bin/python optimize_png.py images_source images_optimized --level 2
Il crée images_optimized si nécessaire et ne traite que les fichiers ordinaires situés directement dans le
répertoire et dont le nom se termine par .png, sans tenir compte de la casse. Il ignore les
sous-répertoires et les liens symboliques. Les espaces et les caractères % littéraux dans les
noms de fichiers sont pris en charge ; pour un chemin en ligne de commande commençant par -,
utilisez un préfixe ./ ou placez -- avant les arguments positionnels.
Chaque sortie existante constitue un échec pour ce fichier. Les autres fichiers sont tout de même traités, les sorties réussies sont conservées et le lot se termine avec le statut 1 si au moins un fichier a échoué. Un répertoire source manquant, un inventaire PNG vide ou des répertoires source et de sortie identiques provoquent un échec avant le traitement. Pour relancer tout le lot, choisissez un nouveau répertoire de sortie.
Cette boucle traite une image à la fois afin de limiter le nombre d’images décodées présentes simultanément en mémoire. Oxipng lui-même prend en charge le multithreading ; ajouter un pool de threads Python n’apporte pas d’accélération démontrée pour cette charge de travail. Les octets d’entrée, les octets de sortie et la mémoire de travail de l’optimiseur doivent tout de même tenir en mémoire pour chaque image.
Choisir le bon niveau d’optimisation
La liaison Python accepte les niveaux 0 à 6, avec 2 par défaut. Il s’agit de préréglages de recherche, et non de réglages de qualité ou d’économies garanties. Commencez par 2 et comparez les résultats sur vos propres ressources.
| Niveau | À essayer pour |
|---|---|
| 0 | Une base de référence rapide |
| 2 | La génération courante des ressources |
| 4 | Une comparaison avec un effort de recherche accru |
| 6 | Une comparaison quand la durée d’exécution importe moins |
Mesurer sans créer de fichiers de test de performance
Enregistrez ce code sous benchmark_png.py à côté de optimize_png.py, puis exécutez
.venv/bin/python benchmark_png.py input.png. Il lit un fichier, essaie quatre niveaux et n’écrit que dans le terminal. Il n’y a
aucun nom de fichier de sortie temporaire susceptible d’entrer en conflit avec vos fichiers.
import argparse
from pathlib import Path
import sys
from time import perf_counter
import oxipng
from optimize_png import optimize_bytes
parser = argparse.ArgumentParser()
parser.add_argument("source", type=Path)
args = parser.parse_args()
try:
data = args.source.read_bytes()
for level in (0, 2, 4, 6):
start = perf_counter()
result = optimize_bytes(data, level)
print(f"level={level}: {len(data)} -> {len(result)} bytes; {perf_counter() - start:.3f} s")
except (OSError, oxipng.PngError) as error:
sys.exit(f"Benchmark failed: {error}")
Chaque niveau part des octets d’origine. La mesure du temps inclut l’optimisation mais exclut la lecture initiale du fichier. Répétez la mesure sur des fichiers représentatifs ; une mesure isolée inclut les effets de préchauffage et de charge du système. Un préréglage plus élevé ne produit pas nécessairement un résultat plus petit pour chaque image.
Conserver ou supprimer les métadonnées
StripChunks.none() ne demande aucune suppression de métadonnées. Ce
n’est pas la même chose que préserver chaque bloc octet par octet : Oxipng peut
supprimer les blocs rendus invalides par un changement de type de couleur ou de profondeur de bits.
Le script crée aussi un nouveau fichier dans le système de fichiers ; il ne copie donc ni les
horodatages ni les permissions du fichier source.
StripChunks.safe() supprime les métadonnées jugées inutiles pour le rendu, y compris les commentaires textuels.
StripChunks.all() peut aussi supprimer les profils de couleur et les blocs d’animation. N’utilisez ni l’un ni
l’autre à la place lorsque votre tâche exige de conserver ces informations. Ni un fichier plus
petit ni un tampon de pixels identique ne prouvent que les métadonnées ont été conservées.
Inspectez les champs précis dont votre application a besoin.
Comparer les pixels décodés
Enregistrez ce code sous check_pixels.py et exécutez .venv/bin/python check_pixels.py input.png output.png.
Pour des PNG sources statiques en RGB/RGBA 8 bits, il compare les dimensions et les octets RGBA
décodés, y compris les valeurs de couleur sous les pixels entièrement transparents. Un fichier
optimisé peut utiliser une palette ; les deux images sont donc décodées dans un mode commun avant la
comparaison.
import argparse
import sys
from PIL import Image
parser = argparse.ArgumentParser()
parser.add_argument("source")
parser.add_argument("output")
args = parser.parse_args()
try:
with Image.open(args.source) as before, Image.open(args.output) as after:
if before.format != "PNG" or after.format != "PNG":
raise ValueError("Both files must be PNGs")
if before.n_frames != 1 or after.n_frames != 1:
raise ValueError("This check covers static PNGs only")
if before.mode not in ("RGB", "RGBA"):
raise ValueError("Use an 8-bit RGB or RGBA source for this check")
if before.size != after.size or before.convert("RGBA").tobytes() != after.convert("RGBA").tobytes():
raise ValueError("Decoded pixels differ")
print("Decoded RGBA pixels match")
except (OSError, ValueError) as error:
sys.exit(f"Pixel check failed: {error}")
tobytes() de Pillow
renvoie les données de pixels décodées. Cette comparaison n’applique pas les profils ICC et ne
compare ni le texte, ni les données EXIF, ni la résolution, ni les autres métadonnées. Fournissez des
sources 8 bits comme indiqué : le mode RGB de Pillow ne suffit pas à lui seul à établir la
profondeur de bits du PNG d’origine.
Dépannage
| Erreur | Action |
|---|---|
ModuleNotFoundError | Utilisez le .venv/bin/python du projet et répétez-y l’étape d’installation. |
FileExistsError | Choisissez une nouvelle destination ; inspectez une ancienne sortie avant de la supprimer vous-même. |
FileNotFoundError | Vérifiez la source et le répertoire parent de la sortie. |
PngError | Vérifiez que l’entrée est un PNG complet et valide. |
PermissionError | Choisissez une source lisible et un répertoire de sortie accessible en écriture. |
Un lot en échec peut laisser des sorties réussies à côté des échecs. Lisez stderr pour connaître les noms des fichiers concernés ; le statut de sortie décrit le lot, pas une annulation des modifications. Si l’utilisation de la mémoire est trop élevée, gardez la boucle séquentielle et testez des images plus petites avant d’augmenter le niveau d’optimisation.
Solution gérée
Pour un flux de travail de téléversement géré par Transloadit, consultez la documentation de /image/optimize (English) et choisissez sa politique de métadonnées indépendamment des réglages du script local.
