Pipeline de redimensionado y optimización masiva de imágenes con Python
Procesar cientos o miles de imágenes manualmente es ineficiente. Python permite construir pipelines automatizados que redimensionan, comprimen, convierten a formatos modernos (WebP, AVIF) y generan variantes responsive de forma eficiente. Esta guía cubre Pillow, wand (binding de ImageMagick) y Pillow-SIMD para máximo rendimiento.
Instalación
pip install Pillow pillow-avif-plugin wand
# Para SIMD (más rápido en CPUs modernas):
pip uninstall Pillow
pip install Pillow-SIMD
En sistemas Linux instala ImageMagick si usas wand:
apt-get install libmagickwand-dev
Redimensionado básico con Pillow
from PIL import Image
from pathlib import Path
def redimensionar(ruta_entrada, ruta_salida, ancho=None, alto=None, max_lado=None):
"""
Redimensiona manteniendo la relación de aspecto.
- ancho/alto: fija una dimensión y calcula la otra
- max_lado: limita el lado más largo a N píxeles
"""
img = Image.open(ruta_entrada)
w, h = img.size
if max_lado:
ratio = max_lado / max(w, h)
nuevo_ancho = int(w * ratio)
nuevo_alto = int(h * ratio)
elif ancho and not alto:
ratio = ancho / w
nuevo_ancho = ancho
nuevo_alto = int(h * ratio)
elif alto and not ancho:
ratio = alto / h
nuevo_ancho = int(w * ratio)
nuevo_alto = alto
else:
nuevo_ancho, nuevo_alto = ancho, alto
# LANCZOS = mejor calidad para reducción
img_redimensionada = img.resize((nuevo_ancho, nuevo_alto), Image.LANCZOS)
img_redimensionada.save(ruta_salida)
print(f"{Path(ruta_entrada).name}: {w}×{h} → {nuevo_ancho}×{nuevo_alto}")
return img_redimensionada
# Uso
redimensionar("foto.jpg", "foto_800.jpg", ancho=800)
redimensionar("imagen.png", "miniatura.png", max_lado=256)
Recorte inteligente (Smart Crop)
from PIL import Image, ImageFilter
import numpy as np
def recortar_centrado(img, ancho_obj, alto_obj):
"""Recorte centrado simple."""
w, h = img.size
izq = (w - ancho_obj) // 2
sup = (h - alto_obj) // 2
return img.crop((izq, sup, izq + ancho_obj, sup + alto_obj))
def recortar_con_atencion(img, ancho_obj, alto_obj):
"""
Recorte basado en mapa de saliencia simplificado:
detecta la región más 'interesante' usando gradientes.
"""
# Convertir a escala de grises y calcular bordes
gris = img.convert('L')
bordes = np.array(gris.filter(ImageFilter.FIND_EDGES))
# Calcular centroide del mapa de saliencia
total = bordes.sum()
if total == 0:
return recortar_centrado(img, ancho_obj, alto_obj)
y_idx, x_idx = np.indices(bordes.shape)
cx = int((bordes * x_idx).sum() / total)
cy = int((bordes * y_idx).sum() / total)
w, h = img.size
# Calcular recorte centrado en (cx, cy) pero dentro de los límites
izq = max(0, min(cx - ancho_obj // 2, w - ancho_obj))
sup = max(0, min(cy - alto_obj // 2, h - alto_obj))
return img.crop((izq, sup, izq + ancho_obj, sup + alto_obj))
# Generar thumbnail 1:1 para redes sociales
img = Image.open("foto_retrato.jpg")
thumb = recortar_con_atencion(img, 1080, 1080)
thumb.save("foto_instagram.jpg", quality=85)
Conversión a WebP y AVIF
from PIL import Image
import pillow_avif # registra el codec AVIF en Pillow
def convertir_a_webp(ruta_entrada, ruta_salida, calidad=85, sin_perdida=False):
img = Image.open(ruta_entrada).convert('RGB')
img.save(
ruta_salida,
format='WEBP',
quality=calidad,
lossless=sin_perdida,
method=6 # 0=más rápido, 6=mejor compresión
)
tam_orig = Path(ruta_entrada).stat().st_size
tam_webp = Path(ruta_salida).stat().st_size
ahorro = (1 - tam_webp / tam_orig) * 100
print(f"WebP: {tam_orig/1024:.0f}KB → {tam_webp/1024:.0f}KB (ahorro: {ahorro:.1f}%)")
def convertir_a_avif(ruta_entrada, ruta_salida, calidad=60):
img = Image.open(ruta_entrada).convert('RGB')
img.save(ruta_salida, format='AVIF', quality=calidad)
tam_orig = Path(ruta_entrada).stat().st_size
tam_avif = Path(ruta_salida).stat().st_size
ahorro = (1 - tam_avif / tam_orig) * 100
print(f"AVIF: {tam_orig/1024:.0f}KB → {tam_avif/1024:.0f}KB (ahorro: {ahorro:.1f}%)")
convertir_a_webp("foto.jpg", "foto.webp")
convertir_a_avif("foto.jpg", "foto.avif")
Pipeline completo para sitio web (variantes responsive)
Genera todas las variantes que un <picture> moderno necesita:
from PIL import Image
import pillow_avif
from pathlib import Path
import json
# Breakpoints responsive estándar
VARIANTES = [
{'sufijo': 'sm', 'max_ancho': 480, 'calidad_webp': 80, 'calidad_avif': 55},
{'sufijo': 'md', 'max_ancho': 768, 'calidad_webp': 82, 'calidad_avif': 58},
{'sufijo': 'lg', 'max_ancho': 1200, 'calidad_webp': 85, 'calidad_avif': 60},
{'sufijo': 'xl', 'max_ancho': 1920, 'calidad_webp': 87, 'calidad_avif': 62},
{'sufijo': '2x', 'max_ancho': 2560, 'calidad_webp': 85, 'calidad_avif': 60},
]
def pipeline_imagen_web(ruta_origen, carpeta_salida):
carpeta_salida = Path(carpeta_salida)
carpeta_salida.mkdir(parents=True, exist_ok=True)
img_orig = Image.open(ruta_origen)
w_orig, h_orig = img_orig.size
nombre_base = Path(ruta_origen).stem
metadatos = {
'original': {'width': w_orig, 'height': h_orig},
'variantes': []
}
for v in VARIANTES:
if w_orig <= v['max_ancho']:
# No escalar si ya es más pequeña
img = img_orig.copy()
nuevo_w, nuevo_h = w_orig, h_orig
else:
ratio = v['max_ancho'] / w_orig
nuevo_w = v['max_ancho']
nuevo_h = int(h_orig * ratio)
img = img_orig.resize((nuevo_w, nuevo_h), Image.LANCZOS)
img_rgb = img.convert('RGB')
# WebP
ruta_webp = carpeta_salida / f"{nombre_base}-{v['sufijo']}.webp"
img_rgb.save(str(ruta_webp), format='WEBP',
quality=v['calidad_webp'], method=6)
# AVIF
ruta_avif = carpeta_salida / f"{nombre_base}-{v['sufijo']}.avif"
img_rgb.save(str(ruta_avif), format='AVIF',
quality=v['calidad_avif'])
# JPG fallback
ruta_jpg = carpeta_salida / f"{nombre_base}-{v['sufijo']}.jpg"
img_rgb.save(str(ruta_jpg), format='JPEG',
quality=85, optimize=True, progressive=True)
metadatos['variantes'].append({
'sufijo': v['sufijo'],
'width': nuevo_w,
'height': nuevo_h,
'webp': ruta_webp.name,
'avif': ruta_avif.name,
'jpg': ruta_jpg.name,
'tam_webp': ruta_webp.stat().st_size,
'tam_avif': ruta_avif.stat().st_size,
'tam_jpg': ruta_jpg.stat().st_size,
})
print(f" {v['sufijo']:4} {nuevo_w}×{nuevo_h}: "
f"WebP={ruta_webp.stat().st_size//1024}KB "
f"AVIF={ruta_avif.stat().st_size//1024}KB "
f"JPG={ruta_jpg.stat().st_size//1024}KB")
# Guardar metadatos JSON
meta_path = carpeta_salida / f"{nombre_base}-meta.json"
with open(meta_path, 'w') as f:
json.dump(metadatos, f, indent=2)
return metadatos
# Uso
meta = pipeline_imagen_web("hero_banner.jpg", "public/images/hero/")
Genera HTML automáticamente a partir de los metadatos:
def generar_picture_html(metadatos, alt="", loading="lazy"):
nombre = metadatos['variantes'][0]['webp'].split('-')[0]
sources = []
for fmt in ['avif', 'webp']:
srcset_partes = []
for v in metadatos['variantes']:
ruta = v[fmt]
w = v['width']
srcset_partes.append(f"{ruta} {w}w")
mime = 'image/avif' if fmt == 'avif' else 'image/webp'
sources.append(
f' <source type="{mime}" srcset="{", ".join(srcset_partes)}">'
)
# Fallback JPG
jpg_grandes = [v for v in metadatos['variantes'] if v['width'] >= 800]
fallback_src = jpg_grandes[-1]['jpg'] if jpg_grandes else metadatos['variantes'][0]['jpg']
html = ['<picture>']
html.extend(sources)
html.append(f' <img src="{fallback_src}" alt="{alt}" loading="{loading}">')
html.append('</picture>')
return '\n'.join(html)
html = generar_picture_html(meta, alt="Banner principal", loading="eager")
print(html)
Procesamiento paralelo con ProcessPoolExecutor
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
import os
def procesar_imagen_worker(args):
"""Worker para multiproceso."""
ruta_orig, carpeta_salida = args
try:
pipeline_imagen_web(str(ruta_orig), str(carpeta_salida))
return (True, str(ruta_orig))
except Exception as e:
return (False, f"{ruta_orig}: {e}")
def batch_pipeline(carpeta_origen, carpeta_salida,
extensiones=('jpg', 'jpeg', 'png', 'tiff'),
workers=None):
carpeta_origen = Path(carpeta_origen)
carpeta_salida = Path(carpeta_salida)
workers = workers or os.cpu_count()
imagenes = [f for ext in extensiones
for f in carpeta_origen.rglob(f'*.{ext}')]
print(f"Procesando {len(imagenes)} imágenes con {workers} workers...")
tareas = [(img, carpeta_salida / img.stem) for img in imagenes]
exito = 0
with ProcessPoolExecutor(max_workers=workers) as executor:
futuros = {executor.submit(procesar_imagen_worker, t): t for t in tareas}
for futuro in as_completed(futuros):
ok, msg = futuro.result()
if ok:
exito += 1
print(f" ✓ {Path(msg).name}")
else:
print(f" ✗ {msg}")
print(f"\nCompletado: {exito}/{len(imagenes)} imágenes procesadas")
batch_pipeline("fotos_originales/", "fotos_optimizadas/", workers=4)
Optimización de JPEG con mozjpeg
import subprocess
from pathlib import Path
def optimizar_jpeg_mozjpeg(ruta_entrada, ruta_salida, calidad=85):
"""
Requiere mozjpeg instalado:
apt-get install mozjpeg (Linux)
brew install mozjpeg (macOS)
"""
subprocess.run([
'cjpeg', '-quality', str(calidad),
'-optimize', '-progressive',
'-outfile', str(ruta_salida),
str(ruta_entrada)
], check=True)
tam_orig = Path(ruta_entrada).stat().st_size
tam_nuevo = Path(ruta_salida).stat().st_size
ahorro = (1 - tam_nuevo / tam_orig) * 100
print(f"mozjpeg: {tam_orig//1024}KB → {tam_nuevo//1024}KB (ahorro: {ahorro:.1f}%)")
Eliminar metadatos EXIF (privacidad)
from PIL import Image
import io
def eliminar_exif(ruta_entrada, ruta_salida):
img = Image.open(ruta_entrada)
# Método 1: re-guardar sin metadatos
datos = list(img.getdata())
img_limpia = Image.new(img.mode, img.size)
img_limpia.putdata(datos)
img_limpia.save(ruta_salida)
print(f"EXIF eliminado: {ruta_salida}")
def leer_exif(ruta):
img = Image.open(ruta)
exif = img._getexif()
if exif:
from PIL.ExifTags import TAGS
for tag_id, valor in exif.items():
tag = TAGS.get(tag_id, str(tag_id))
print(f" {tag}: {valor}")
else:
print(" Sin datos EXIF")
print("Antes:")
leer_exif("foto_original.jpg")
eliminar_exif("foto_original.jpg", "foto_limpia.jpg")
print("\nDespués:")
leer_exif("foto_limpia.jpg")
Comparativa de formatos por calidad y tamaño
| Formato | Tamaño típico | Calidad | Compatibilidad | Recomendación |
|---|---|---|---|---|
| JPEG (85%) | 100 KB | Buena | Universal | Fallback obligatorio |
| WebP (85%) | 65 KB (-35%) | Muy buena | 95%+ navegadores | Principal para web |
| AVIF (60%) | 45 KB (-55%) | Excelente | 85%+ navegadores | Prioridad en <picture> |
| PNG | 250 KB | Sin pérdida | Universal | Solo si necesitas alfa |
Recurso adicional
Para convertir imágenes entre JPG, WebP, AVIF, PNG, TIFF y más formatos sin necesidad de programar, usa KaijuConverter — conversión gratuita, rápida y sin registro.
Conversiones relacionadas
Conversiones frecuentes del catálogo: