La compresión de imágenes en batch es esencial para optimizar el rendimiento web. Con Python y Pillow puedes automatizar completamente el proceso: reducir tamaño de archivo, redimensionar, convertir formatos y generar estadísticas.
Instalación
pip install pillow
Compresión básica de JPG y PNG
from PIL import Image
import os
def comprimir_imagen(ruta_entrada, ruta_salida, calidad=85, max_ancho=1920):
"""
Comprime una imagen manteniendo proporción.
calidad: 1-95 (JPEG); 1-9 (PNG compression level)
"""
img = Image.open(ruta_entrada)
# Redimensionar si es más ancha que max_ancho
if img.width > max_ancho:
ratio = max_ancho / img.width
nuevo_alto = int(img.height * ratio)
img = img.resize((max_ancho, nuevo_alto), Image.LANCZOS)
ext = ruta_salida.rsplit('.', 1)[-1].lower()
os.makedirs(os.path.dirname(ruta_salida) or '.', exist_ok=True)
if ext in ('jpg', 'jpeg'):
# Convertir a RGB (sin alfa) para guardar como JPEG
if img.mode in ('RGBA', 'P'):
img = img.convert('RGB')
img.save(ruta_salida, format='JPEG', quality=calidad,
optimize=True, progressive=True)
elif ext == 'png':
img.save(ruta_salida, format='PNG', optimize=True,
compress_level=9) # 0=sin compresión, 9=máxima
elif ext == 'webp':
img.save(ruta_salida, format='WEBP', quality=calidad, method=6)
tam_orig = os.path.getsize(ruta_entrada)
tam_comp = os.path.getsize(ruta_salida)
ahorro = (1 - tam_comp / tam_orig) * 100
return tam_orig, tam_comp, ahorro
# Uso individual
orig, comp, ahorro = comprimir_imagen('foto.jpg', 'foto_opt.jpg', calidad=82)
print(f"{orig//1024}KB → {comp//1024}KB ({ahorro:.0f}% menos)")
Script de compresión masiva con estadísticas
from PIL import Image
from pathlib import Path
import os, time
def comprimir_directorio(
directorio_entrada,
directorio_salida=None,
calidad_jpg=82,
calidad_webp=80,
max_ancho=1920,
convertir_a_webp=False,
):
"""
Comprime todas las imágenes de un directorio.
Si directorio_salida es None, sobrescribe los originales.
"""
origen = Path(directorio_entrada)
destino = Path(directorio_salida) if directorio_salida else origen
destino.mkdir(parents=True, exist_ok=True)
extensiones = {'.jpg', '.jpeg', '.png', '.webp', '.bmp', '.tiff'}
archivos = [f for f in sorted(origen.iterdir())
if f.suffix.lower() in extensiones and f.is_file()]
print(f"Encontrados: {len(archivos)} archivos en {origen}")
print(f"Destino: {destino}\n")
stats = {'ok': 0, 'error': 0, 'bytes_orig': 0, 'bytes_comp': 0}
inicio = time.time()
for archivo in archivos:
try:
img = Image.open(archivo)
tam_orig = archivo.stat().st_size
# Redimensionar si supera el máximo
if img.width > max_ancho:
ratio = max_ancho / img.width
img = img.resize((max_ancho, int(img.height * ratio)), Image.LANCZOS)
# Determinar formato de salida
if convertir_a_webp:
ext_salida = '.webp'
else:
ext_salida = archivo.suffix.lower()
if ext_salida in ('.bmp', '.tiff'):
ext_salida = '.jpg' # convertir formatos legacy a JPG
ruta_salida = destino / (archivo.stem + ext_salida)
if ext_salida in ('.jpg', '.jpeg'):
if img.mode in ('RGBA', 'P', 'LA'):
img = img.convert('RGB')
img.save(ruta_salida, format='JPEG', quality=calidad_jpg,
optimize=True, progressive=True)
elif ext_salida == '.png':
img.save(ruta_salida, format='PNG', optimize=True, compress_level=9)
elif ext_salida == '.webp':
img.save(ruta_salida, format='WEBP', quality=calidad_webp, method=6)
tam_comp = ruta_salida.stat().st_size
ahorro = (1 - tam_comp / tam_orig) * 100
print(f" OK: {archivo.name:40s} "
f"{tam_orig//1024:5d}KB → {tam_comp//1024:5d}KB "
f"({ahorro:+.0f}%)")
stats['ok'] += 1
stats['bytes_orig'] += tam_orig
stats['bytes_comp'] += tam_comp
except Exception as e:
print(f" ERROR: {archivo.name}: {e}")
stats['error'] += 1
# Resumen
duracion = time.time() - inicio
ahorro_total = (1 - stats['bytes_comp'] / max(stats['bytes_orig'], 1)) * 100
print(f"\n{'='*60}")
print(f"Procesados: {stats['ok']} | Errores: {stats['error']}")
print(f"Original: {stats['bytes_orig']//1024//1024:.1f} MB")
print(f"Comprimido: {stats['bytes_comp']//1024//1024:.1f} MB")
print(f"Ahorro: {ahorro_total:.1f}%")
print(f"Tiempo: {duracion:.1f}s")
# Comprimir conservando originales
comprimir_directorio('imagenes_web/', 'imagenes_opt/', calidad_jpg=82)
# Convertir todo a WebP
comprimir_directorio('imagenes_web/', 'imagenes_webp/', convertir_a_webp=True, calidad_webp=80)
Compresión progresiva (encontrar calidad óptima)
from PIL import Image
import os
def calidad_optima_jpg(ruta_img, objetivo_kb=100, min_calidad=40, max_calidad=95):
"""
Encuentra la calidad JPEG mínima que mantiene el archivo
por debajo del tamaño objetivo (en KB).
Usa búsqueda binaria para eficiencia.
"""
import io
img = Image.open(ruta_img)
if img.mode in ('RGBA', 'P'):
img = img.convert('RGB')
objetivo_bytes = objetivo_kb * 1024
bajo, alto = min_calidad, max_calidad
mejor_calidad = alto
while bajo <= alto:
medio = (bajo + alto) // 2
buf = io.BytesIO()
img.save(buf, format='JPEG', quality=medio, optimize=True)
tam = buf.tell()
if tam <= objetivo_bytes:
mejor_calidad = medio
bajo = medio + 1 # intentar calidad más alta
else:
alto = medio - 1 # necesitamos más compresión
# Guardar con la calidad óptima encontrada
buf = io.BytesIO()
img.save(buf, format='JPEG', quality=mejor_calidad, optimize=True)
print(f"Calidad óptima: {mejor_calidad} → {buf.tell()//1024} KB (objetivo: {objetivo_kb} KB)")
return mejor_calidad, buf.getvalue()
calidad, datos = calidad_optima_jpg('foto_grande.jpg', objetivo_kb=150)
with open('foto_optimizada.jpg', 'wb') as f:
f.write(datos)
Generar thumbnails en múltiples tamaños
from PIL import Image
from pathlib import Path
TAMAÑOS_THUMBNAIL = {
'sm': (150, 150),
'md': (300, 300),
'lg': (600, 600),
'xl': (1200, 1200),
}
def generar_thumbnails(ruta_img, directorio_salida='.', cuadrado=False):
"""
Genera thumbnails en múltiples tamaños.
Si cuadrado=True, recorta al centro para obtener imagen cuadrada.
"""
img = Image.open(ruta_img)
nombre = Path(ruta_img).stem
salida = Path(directorio_salida)
salida.mkdir(exist_ok=True)
for sufijo, tam in TAMAÑOS_THUMBNAIL.items():
if cuadrado:
# Recortar al centro (thumbnail cuadrado)
img_tmp = img.copy()
img_tmp.thumbnail((max(tam), max(tam)), Image.LANCZOS)
# Recorte central
w, h = img_tmp.size
left = (w - tam[0]) // 2
top = (h - tam[1]) // 2
img_thumb = img_tmp.crop((left, top, left + tam[0], top + tam[1]))
else:
# Thumbnail manteniendo proporción
img_thumb = img.copy()
img_thumb.thumbnail(tam, Image.LANCZOS)
ruta = salida / f'{nombre}_{sufijo}.jpg'
if img_thumb.mode in ('RGBA', 'P'):
img_thumb = img_thumb.convert('RGB')
img_thumb.save(ruta, format='JPEG', quality=85, optimize=True)
print(f" {sufijo}: {img_thumb.size} → {ruta}")
generar_thumbnails('producto.jpg', 'thumbnails/', cuadrado=True)
Añadir watermark en batch
from PIL import Image, ImageDraw, ImageFont
from pathlib import Path
def watermark_batch(directorio, texto='© MiMarca', opacidad=0.3):
"""Añade watermark de texto a todas las imágenes de un directorio."""
origen = Path(directorio)
salida = origen / 'watermarked'
salida.mkdir(exist_ok=True)
try:
font = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 36)
except (IOError, OSError):
font = ImageFont.load_default()
for ruta in sorted(origen.glob('*.jpg')):
img = Image.open(ruta).convert('RGBA')
# Capa de watermark transparente
overlay = Image.new('RGBA', img.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
# Posición: esquina inferior derecha
bbox = draw.textbbox((0, 0), texto, font=font)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
x = img.width - tw - 20
y = img.height - th - 20
draw.text((x, y), texto, fill=(255, 255, 255, int(255 * opacidad)), font=font)
# Combinar
resultado = Image.alpha_composite(img, overlay).convert('RGB')
resultado.save(salida / ruta.name, format='JPEG', quality=88, optimize=True)
print(f" Watermark: {ruta.name}")
watermark_batch('productos/', texto='© TiendaEjemplo 2024')
Conversiones relacionadas
Conversiones frecuentes del catálogo: