Optimización eficiente de PNG en Python con Oxipng
Usa pyoxipng para comprimir un PNG en memoria y guardarlo solo si el destino
no existe. Esta guía te ofrece un script para archivos individuales y lotes pequeños, con mediciones
de tamaño, códigos de salida distintos de cero si hay fallos y una comprobación independiente de
que los píxeles decodificados no cambian.
OptiPNG frente a Oxipng
OptiPNG y
Oxipng son optimizadores de PNG distintos. El paquete de Python que
usamos aquí, pyoxipng, integra la biblioteca de
Rust de Oxipng y se importa como oxipng. No necesitas un ejecutable de OptiPNG
ni un subproceso para llamarlo.
Decide qué conservar
La optimización sin pérdida puede cambiar la compresión, la paleta o el tipo de color de un PNG sin alterar sus píxeles decodificados. No promete un porcentaje de reducción específico. Un archivo que ya es compacto podría no reducirse en absoluto.
El ejemplo mantiene intactos los archivos de origen, rechaza los destinos existentes y no solicita
la eliminación de metadatos. También mantiene sin cambios los colores de los píxeles transparentes
mediante optimize_alpha=False. Úsalo para archivos PNG estáticos locales en directorios que
controles. La comprobación de píxeles que aparece más adelante abarca entradas RGB y RGBA de 8 bits;
no valida la animación, la precisión de 16 bits ni la apariencia con gestión del color.
Requisitos previos
Los comandos siguientes usan Bash y Python 3.12 con venv y
pip. Se probaron en Linux con Python 3.12.13 y
pyoxipng 9.1.1. Empieza en un directorio donde aún no exista
png-demo:
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 cadena && detiene la preparación si falla un paso, incluso si ya existe
el directorio png-demo. La opción --only-binary exige un wheel
compatible en lugar de iniciar una compilación del código fuente de Rust.
PyPI muestra los wheels disponibles. Pillow se usa solo para la
comprobación de píxeles, no para la optimización. Ejecuta los comandos restantes desde
png-demo.
Optimiza un solo PNG
Guarda lo siguiente como optimize_png.py. El script pasa los bytes codificados del PNG a
optimize_from_memory,
por lo que no reconstruye la imagen a partir de un array de píxeles de Pillow o 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())
Coloca un PNG llamado input.png junto al script y ejecuta:
.venv/bin/python optimize_png.py input.png output.png
Si se ejecuta correctamente, imprime los recuentos de bytes de entrada y salida, los bytes ahorrados
y los segundos transcurridos, incluidas las operaciones de E/S de archivos. Un resultado de cero
bytes ahorrados es válido: el script escribe una copia del original cuando el resultado candidato
no es más pequeño. Si ejecutas de nuevo el mismo comando, informa de
FileExistsError, termina con el código de salida 1 y deja intacto
output.png. Usar el propio archivo de entrada como destino también provoca un
fallo sin modificarlo.
El modo "xb" de Python toma la decisión de
no sobrescribir al crear el archivo. Una entrada dañada o inexistente provoca un fallo antes de
ese punto. Si la escritura o el cierre del archivo recién creado genera
OSError, el script intenta eliminar esa salida parcial. Este es un script
local, no un mecanismo de publicación atómica: una terminación forzada puede dejar un archivo
parcial, y ningún otro proceso debe consumir el destino hasta que la ejecución termine correctamente.
Optimiza un directorio
Coloca tus PNG en images_source y usa el mismo script:
.venv/bin/python optimize_png.py images_source images_optimized --level 2
Crea images_optimized si es necesario y procesa solo los archivos regulares del nivel
inmediato que terminan en .png, sin distinguir mayúsculas de minúsculas.
Omite los subdirectorios y las entradas de enlaces simbólicos. Admite espacios y caracteres
% literales en los nombres de archivo; para una ruta de línea de comandos
que empiece por -, usa el prefijo ./ o coloca
-- antes de los argumentos posicionales.
Cada salida existente provoca un fallo para ese archivo. Los demás archivos se siguen procesando, las salidas correctas se conservan y el lote termina con el código de salida 1 si algún archivo falló. Un directorio de origen inexistente, un inventario de PNG vacío o directorios de origen y salida idénticos provocan un fallo antes del procesamiento. Para volver a ejecutar todo el lote, elige un directorio de salida nuevo.
Este bucle procesa una imagen a la vez para limitar cuántas imágenes decodificadas residen en memoria simultáneamente. Oxipng admite por sí mismo el procesamiento multihilo; no se ha demostrado que añadir un grupo de hilos de Python acelere esta carga de trabajo. Los bytes de entrada, los bytes de salida y la memoria de trabajo del optimizador deben caber en memoria para cada imagen.
Elección del nivel de optimización adecuado
La integración acepta niveles de 0 a 6, con 2 como valor predeterminado. Son ajustes preestablecidos de búsqueda, no ajustes de calidad ni ahorros garantizados. Empieza con 2 y compara los resultados con tus propios archivos.
| Nivel | Pruébalo para |
|---|---|
| 0 | Una referencia rápida |
| 2 | Generación habitual de archivos |
| 4 | Una comparación con mayor esfuerzo de búsqueda |
| 6 | Una comparación cuando el tiempo de ejecución importa menos |
Mide sin crear archivos de pruebas de rendimiento
Guarda esto como benchmark_png.py junto a optimize_png.py y ejecuta
.venv/bin/python benchmark_png.py input.png. Lee un archivo, prueba cuatro niveles y escribe solo en la terminal.
No hay ningún nombre de archivo de salida temporal que pueda entrar en conflicto con tus archivos.
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}")
Cada nivel parte de los bytes originales. La medición del tiempo incluye la optimización, pero excluye la lectura inicial del archivo. Repite la medición con archivos representativos; una sola medición incluye los efectos del calentamiento y de la carga del sistema. Un ajuste preestablecido más alto no necesariamente produce un resultado más pequeño para cada imagen.
Conservación o eliminación de metadatos
StripChunks.none() solicita que no se eliminen metadatos.
Eso no equivale a conservar cada bloque byte por byte: Oxipng puede
eliminar bloques invalidados por un cambio de tipo de color o profundidad de bits.
El script también crea un archivo nuevo en el sistema de archivos, por lo que no copia las marcas
de tiempo ni los permisos del archivo de origen.
StripChunks.safe() elimina los metadatos que se consideran innecesarios para la
renderización, incluidos los comentarios de texto.
StripChunks.all() también puede eliminar perfiles de color y bloques de animación.
No sustituyas el ajuste por ninguna de estas opciones cuando tu tarea requiera conservar esa
información. Ni un archivo más pequeño ni un búfer de píxeles idéntico demuestran que los metadatos
se hayan conservado. Inspecciona los campos específicos que necesita tu aplicación.
Compara los píxeles decodificados
Guarda esto como check_pixels.py y ejecuta .venv/bin/python check_pixels.py input.png output.png.
Para PNG de origen estáticos RGB/RGBA de 8 bits, compara las dimensiones y los bytes RGBA
decodificados, incluidos los valores de color de los píxeles totalmente transparentes. Un archivo
optimizado puede usar una paleta, por lo que ambas imágenes se decodifican en un modo común antes
de compararlas.
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
devuelve datos de píxeles decodificados. Esta comparación no aplica perfiles ICC ni compara texto,
EXIF, resolución u otros metadatos. Proporciona archivos de origen de 8 bits, como se indica:
el modo RGB de Pillow por sí solo no permite determinar la profundidad de bits del PNG original.
Resolución de problemas
| Error | Acción |
|---|---|
ModuleNotFoundError | Usa .venv/bin/python del proyecto y repite allí el paso de instalación. |
FileExistsError | Elige un destino nuevo; inspecciona una salida anterior antes de eliminarla por tu cuenta. |
FileNotFoundError | Revisa el origen y el directorio padre de la salida. |
PngError | Comprueba que la entrada sea un PNG completo y válido. |
PermissionError | Elige un origen que permita la lectura y un directorio de salida que permita la escritura. |
Un lote fallido puede dejar salidas correctas junto a los fallos. Lee stderr para identificar los nombres de archivo afectados; el código de salida describe el lote, no una reversión de cambios. Si el uso de memoria es demasiado alto, mantén el bucle secuencial y prueba imágenes más pequeñas antes de aumentar el nivel de optimización.
Alternativa gestionada
Para un flujo de trabajo de subida gestionado por Transloadit, consulta la documentación de /image/optimize y elige su política de metadatos por separado de los ajustes del script local.
