Comprimir por lotes 1000 imágenes con Python Pillow: Guía completa

Por qué comprimir imágenes por lotes

Tienes 1000 fotos de alta resolución exportadas desde una cámara, cada una de unos 8 MB, y todo el directorio consume casi 8 GB. Si el objetivo es subirlas a un sitio web, integrarlas en una presentación o enviarlas por correo electrónico, ese volumen es totalmente inaceptable. Comprimirlas una por una en un editor gráfico es a la vez ineficiente y propenso a errores; justo aquí es donde brilla el procesamiento por lotes mediante scripts.

La biblioteca Pillow de Python es el estándar de facto para el procesamiento de imágenes. Ofrece soporte de lectura y escritura para los formatos principales, incluidos JPEG, PNG, WebP, BMP y TIFF, así como un control fino sobre los factores de calidad, las tasas de muestreo y los modos de color. Con unas pocas docenas de líneas de Python, puedes recorrer todo un árbol de directorios, aplicar una estrategia de compresión uniforme y preservar la estructura de carpetas original en la salida.

Este artículo parte del uso básico de Pillow y construye progresivamente un script de compresión por lotes completo, ejecutable y multihilo. También cubre detalles de ingeniería prácticos como la preservación EXIF, la conversión de formato y la selección inteligente de parámetros.


Instalación y uso básico de Pillow

Instalación

Pillow es el fork mantenido activamente de PIL (Python Imaging Library), pero el nombre de importación sigue siendo PIL. Instálalo con pip:


pip install Pillow

Si necesitas manejar un gran volumen de imágenes o quieres una decodificación más rápida, instala la versión completa con soporte de plugins:


pip install "Pillow[all]"

Después de la instalación, verifica la versión:


from PIL import Image, __version__
print(__version__)  # p. ej., 10.4.0

Operaciones básicas

El punto de entrada principal de Pillow es la clase Image. Las tres operaciones más comunes son abrir, modificar y guardar:


from PIL import Image

# Abrir una imagen (carga diferida; los datos de píxel se leen en el primer acceso)
img = Image.open("photo.jpg")
print(img.format, img.size, img.mode)  # JPEG (4000, 3000) RGB

# Obtener el valor de un píxel
pixel = img.getpixel((100, 100))

# Redimensionar
resized = img.resize((1920, 1080))

# Guardar
resized.save("photo_small.jpg", quality=85)

Algunos detalles merecen atención: el objeto devuelto por Image.open() mantiene una referencia al archivo que no se libera hasta que se cierra explícitamente o sale del bloque with. En el procesamiento por lotes, usa siempre un gestor de contexto o llama a close() explícitamente; de lo contrario, los descriptores de archivo se filtrarán.


Ejemplos de compresión de una sola imagen

La base de la compresión por lotes es el procesamiento de una sola imagen. Primero implementamos una función que admite el ajuste de calidad JPEG, la reducción de resolución y la conversión de formato.

Ajuste de calidad JPEG

El parámetro quality de JPEG va de 1 a 100 y controla directamente el escalado de la tabla de cuantificación. Es el control principal para equilibrar el tamaño del archivo y la calidad de la imagen:


from PIL import Image

def compress_jpeg(input_path, output_path, quality=75):
    """Comprimir un JPEG con el factor de calidad especificado"""
    with Image.open(input_path) as img:
        # JPEG no admite transparencia; los modos RGBA/P deben convertirse primero
        if img.mode in ("RGBA", "P", "LA"):
            img = img.convert("RGB")
        elif img.mode != "RGB":
            img = img.convert("RGB")
        img.save(
            output_path,
            format="JPEG",
            quality=quality,
            optimize=True,       # Activar la optimización de la codificación Huffman
            progressive=True,    # Generar un JPEG progresivo; ligeramente más grande pero mejor experiencia de carga
        )

# Comparar diferentes factores de calidad
compress_jpeg("photo.jpg", "q95.jpg", quality=95)
compress_jpeg("photo.jpg", "q75.jpg", quality=75)
compress_jpeg("photo.jpg", "q50.jpg", quality=50)

optimize=True hace que Pillow escanee los datos una vez más al guardar para reconstruir una tabla de codificación Huffman óptima. Esto suele reducir el tamaño del archivo en un 2-5 % adicional a costa de un guardado ligeramente más lento.

Ajuste de resolución

Para la visualización web, un original de 4000x3000 es mucho más grande de lo necesario. Usar thumbnail() para reducir proporcionalmente es el enfoque más eficaz: reducir a la mitad la resolución disminuye el tamaño del archivo en aproximadamente un 75 %:


from PIL import Image

def resize_image(input_path, output_path, max_size=1920, quality=80):
    """Escalar el lado más largo a max_size preservando la relación de aspecto"""
    with Image.open(input_path) as img:
        # thumbnail modifica en el lugar y nunca supera las dimensiones especificadas
        # Image.Resampling.LANCZOS es el mejor algoritmo de submuestreo en calidad
        img.thumbnail((max_size, max_size), Image.Resampling.LANCZOS)
        if img.mode != "RGB":
            img = img.convert("RGB")
        img.save(output_path, format="JPEG", quality=quality, optimize=True)

resize_image("photo.jpg", "photo_1920.jpg", max_size=1920, quality=80)

La diferencia entre thumbnail() y resize() es que thumbnail() preserva la relación de aspecto y nunca agranda la imagen: las dimensiones finales siempre son menores o iguales a los valores especificados. resize() fuerza la imagen a las dimensiones exactas objetivo. En el procesamiento por lotes, casi siempre querrás thumbnail().

Conversión de formato

Convertir PNG a WebP es una optimización común: WebP es un 26 % más pequeño que PNG y un 25-35 % más pequeño que JPEG con calidad equivalente, y además admite transparencia:


from PIL import Image

def convert_to_webp(input_path, output_path, quality=80, lossless=False):
    """Convertir cualquier formato a WebP"""
    with Image.open(input_path) as img:
        img.save(
            output_path,
            format="WebP",
            quality=quality,
            lossless=lossless,   # Cuando es True, usa el modo sin pérdida
            method=6,            # Esfuerzo de compresión 0-6; 6 es el más lento pero el más pequeño
        )

convert_to_webp("icon.png", "icon.webp", quality=85)
convert_to_webp("logo.png", "logo_lossless.webp", lossless=True)

El parámetro method controla el esfuerzo de compresión, de 0 a 6. Los valores más altos ofrecen mejores ratios de compresión pero tardan más. Para el procesamiento por lotes donde el tamaño final del archivo importa, establécelo en 6; para velocidad, 4 es un buen equilibrio.


Script completo de compresión por lotes

Ahora ensamblamos la lógica de una sola imagen en un script completo por lotes. Este script recorre todas las imágenes del directorio de entrada, preserva la estructura de carpetas original en la salida y muestra el progreso del procesamiento junto con los resultados generales de compresión.

El diagrama de flujo siguiente ilustra el flujo de procesamiento por lotes general:

Python batch image compression processing flowchart: directory traversal, PIL/Pillow processing, quality adjustment, output

El script completo:


"""
batch_compress.py - Script de compresión de imágenes por lotes
Uso: python batch_compress.py <input_dir> <output_dir> [--quality 75] [--max-size 1920]
"""
import os
import sys
import argparse
from pathlib import Path
from PIL import Image

SUPPORTED_FORMATS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff", ".tif"}


def compress_single(input_path, output_path, quality=75, max_size=1920,
                    target_format=None, keep_exif=True):
    """Comprimir una sola imagen"""
    with Image.open(input_path) as img:
        # 1. Extraer datos EXIF (hora de captura, modelo de cámara, GPS, etc.)
        exif_data = img.info.get("exif", b"") if keep_exif else b""

        # 2. Conversión de modo de color: JPEG no admite transparencia
        out_format = (target_format or img.format or "JPEG").upper()
        if out_format in ("JPEG", "JPG") and img.mode in ("RGBA", "P", "LA"):
            # Componer sobre un fondo blanco para evitar regiones negras tras la conversión
            background = Image.new("RGB", img.size, (255, 255, 255))
            if img.mode == "P":
                img = img.convert("RGBA")
            background.paste(img, mask=img.split()[-1] if img.mode == "RGBA" else None)
            img = background
        elif img.mode not in ("RGB", "RGBA", "L"):
            img = img.convert("RGB")

        # 3. Ajuste de resolución: solo reducir, nunca ampliar
        if max_size and max(img.size) > max_size:
            img.thumbnail((max_size, max_size), Image.Resampling.LANCZOS)

        # 4. Guardar
        save_kwargs = {"optimize": True}
        if out_format in ("JPEG", "JPG"):
            save_kwargs["quality"] = quality
            save_kwargs["progressive"] = True
            if exif_data:
                save_kwargs["exif"] = exif_data
        elif out_format == "WEBP":
            save_kwargs["quality"] = quality
            save_kwargs["method"] = 6
        elif out_format == "PNG":
            save_kwargs["optimize"] = True

        img.save(output_path, format=out_format, **save_kwargs)


def batch_compress(input_dir, output_dir, quality=75, max_size=1920,
                   target_format=None, keep_exif=True):
    """Comprimir por lotes un directorio entero"""
    input_dir = Path(input_dir)
    output_dir = Path(output_dir)

    # Recopilar todos los archivos a procesar
    tasks = []
    for root, _, files in os.walk(input_dir):
        for filename in files:
            if Path(filename).suffix.lower() in SUPPORTED_FORMATS:
                src = Path(root) / filename
                rel = src.relative_to(input_dir)
                dst = output_dir / rel
                # Ajustar la extensión según el formato objetivo
                if target_format:
                    dst = dst.with_suffix(f".{target_format.lower()}")
                tasks.append((src, dst))

    total = len(tasks)
    if total == 0:
        print("No se encontraron archivos de imagen compatibles")
        return

    print(f"Se encontraron {total} imágenes. Iniciando compresión...\n")

    processed = 0
    total_original = 0
    total_compressed = 0
    errors = []

    for src, dst in tasks:
        # Asegurar que el directorio de salida existe
        dst.parent.mkdir(parents=True, exist_ok=True)

        original_size = src.stat().st_size
        try:
            compress_single(src, dst, quality, max_size, target_format, keep_exif)
            new_size = dst.stat().st_size
            total_original += original_size
            total_compressed += new_size
        except Exception as e:
            errors.append((src, str(e)))
            new_size = original_size  # Contar como tamaño original en caso de fallo

        processed += 1
        pct = processed / total * 100
        saved = (1 - new_size / original_size) * 100 if original_size else 0
        print(f"[{pct:5.1f}%] ({processed}/{total}) {src.relative_to(input_dir)}  "
              f"{original_size // 1024}KB -> {new_size // 1024}KB  (-{saved:.1f}%)")

    # Informe resumido
    print(f"\n{'=' * 50}")
    print(f"Terminado: {processed} exitosos, {len(errors)} fallidos")
    if total_original > 0:
        ratio = total_original / total_compressed if total_compressed else 0
        saved_mb = (total_original - total_compressed) / 1024 / 1024
        print(f"Tamaño original: {total_original / 1024 / 1024:.2f} MB")
        print(f"Tamaño comprimido: {total_compressed / 1024 / 1024:.2f} MB")
        print(f"Espacio ahorrado: {saved_mb:.2f} MB (ratio {ratio:.2f}x)")

    for src, err in errors:
        print(f"  Fallido: {src} - {err}")


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Herramienta de compresión de imágenes por lotes")
    parser.add_argument("input_dir", help="Directorio de imágenes de entrada")
    parser.add_argument("output_dir", help="Directorio de salida")
    parser.add_argument("--quality", type=int, default=75, help="Factor de calidad JPEG/WebP (1-100)")
    parser.add_argument("--max-size", type=int, default=1920, help="Lado más largo máximo en píxeles")
    parser.add_argument("--format", default=None, help="Formato objetivo (JPEG/WebP/PNG)")
    parser.add_argument("--no-exif", action="store_true", help="Descartar datos EXIF")
    args = parser.parse_args()

    batch_compress(
        args.input_dir,
        args.output_dir,
        quality=args.quality,
        max_size=args.max_size,
        target_format=args.format,
        keep_exif=not args.no_exif,
    )

Ejemplos de uso:


# Uso básico
python batch_compress.py ./photos ./output

# Convertir a WebP, calidad 80, lado más largo máximo 2560
python batch_compress.py ./photos ./output --quality 80 --max-size 2560 --format webp

Este script se basa en tres principios de diseño fundamentales: usa os.walk() para recorrer de forma recursiva y preservar la estructura de directorios; usa relative_to() para calcular las rutas relativas garantizando que la disposición de salida coincida con la entrada; y usa Path.stat() para obtener los tamaños de archivo y calcular el efecto real de compresión.


Técnicas avanzadas

Preservación de la información EXIF

Los datos EXIF contienen la hora de captura, las coordenadas GPS, los parámetros de la cámara, la apertura, la velocidad de obturación y otros metadatos. Por defecto, img.save() descarta esta información. Para preservarla, debes extraerla manualmente y pasarla al guardar:


from PIL import Image

def compress_with_exif(input_path, output_path, quality=75):
    with Image.open(input_path) as img:
        exif = img.info.get("exif", b"")  # Extraer los bytes EXIF sin procesar
        if img.mode != "RGB":
            img = img.convert("RGB")
        img.save(
            output_path,
            format="JPEG",
            quality=quality,
            exif=exif if exif else None,  # Pasar None cuando no hay EXIF
            optimize=True,
        )

Si solo necesitas conservar ciertos campos EXIF (por ejemplo, solo la etiqueta de orientación), usa el método getexif() para seleccionar elementos individualmente:


from PIL import ExifTags

def keep_orientation_only(input_path, output_path, quality=75):
    with Image.open(input_path) as img:
        exif = img.getexif()
        orientation = exif.get(0x0112)  # Etiqueta de orientación
        # Rotar la imagen según la etiqueta de orientación
        if orientation:
            from PIL import ImageOps
            img = ImageOps.exif_transpose(img)
        img.save(output_path, format="JPEG", quality=quality, optimize=True)

ImageOps.exif_transpose() rota automáticamente la imagen según la etiqueta de orientación EXIF. Esto es especialmente útil al procesar fotos tomadas con smartphones: muchos sensores de teléfono están montados en orientación horizontal y dependen de la etiqueta de orientación EXIF para indicar la dirección de visualización correcta.

PNG a WebP

Al convertir PNG a WebP, necesitas elegir entre los modos con pérdida y sin pérdida según las características de la imagen. Para imágenes con texto y líneas nítidas (iconos, elementos de interfaz), el WebP sin pérdida preserva bordes definidos. Para contenido fotográfico, el WebP con pérdida ofrece una ventaja de tamaño significativa:


from PIL import Image

def png_to_webp(input_path, output_path, lossless_threshold=0.3):
    """Elegir inteligentemente entre WebP con o sin pérdida según las características de la imagen"""
    with Image.open(input_path) as img:
        # Contar colores para determinar si se trata de un gráfico simple
        colors = img.getcolors(maxcolors=65536)
        is_simple = colors is not None and len(colors) < 256

        if is_simple:
            # Pocos colores, bordes nítidos: modo sin pérdida
            img.save(output_path, format="WebP", lossless=True, method=6)
            print(f"WebP sin pérdida (recuento de colores: {len(colors)})")
        else:
            # Colores ricos, fotográfico: modo con pérdida
            if img.mode != "RGB":
                img = img.convert("RGB")
            img.save(output_path, format="WebP", quality=82, method=6)
            print("WebP con pérdida (fotográfico)")

getcolors() devuelve una lista de colores o None (cuando el recuento de colores supera maxcolors). Un número reducido de colores suele indicar un gráfico simple como un icono o una captura de pantalla, más adecuado para la compresión sin pérdida.

Selección inteligente de parámetros

Diferentes imágenes se adaptan mejor a diferentes estrategias de compresión. La siguiente función selecciona automáticamente el formato y los parámetros en función de la resolución, la transparencia y el recuento de colores:


from PIL import Image

def smart_compress(input_path, output_path):
    """Seleccionar automáticamente la estrategia de compresión óptima según las características de la imagen"""
    with Image.open(input_path) as img:
        width, height = img.size
        pixel_count = width * height
        mode = img.mode
        has_transparency = mode in ("RGBA", "LA") or (
            mode == "P" and "transparency" in img.info
        )

        # Estrategia 1: Tiene transparencia -> WebP (equilibra transparencia y tamaño)
        if has_transparency:
            if pixel_count > 2_000_000:
                q = 80
            else:
                q = 88
            img.save(output_path, format="WebP", quality=q, method=6)
            return "WebP con pérdida (transparente)"

        # Estrategia 2: Foto muy grande -> reducir + JPEG calidad media
        if pixel_count > 8_000_000 or width > 4000:
            img = img.convert("RGB")
            img.thumbnail((2560, 2560), Image.Resampling.LANCZOS)
            img.save(output_path, format="JPEG", quality=75, optimize=True)
            return "JPEG (imagen grande reducida)"

        # Estrategia 3: Foto normal -> JPEG calidad superior
        if pixel_count > 500_000:
            img = img.convert("RGB")
            img.save(output_path, format="JPEG", quality=82, optimize=True)
            return "JPEG (alta calidad)"

        # Estrategia 4: Imagen pequeña -> mantener como PNG
        img.save(output_path, format="PNG", optimize=True)
        return "PNG (imagen pequeña preservada)"

La lógica central de esta estrategia es: las imágenes con transparencia van a WebP, las imágenes grandes se reducen antes de comprimir, las fotos normales usan JPEG y las imágenes pequeñas usan PNG para fidelidad. Puedes ajustar los umbrales según tus necesidades reales.


Optimización del rendimiento

Procesamiento multihilo

La compresión de imágenes es una tarea que mezcla trabajo limitado por E/S y trabajo limitado por CPU. La lectura/escritura del disco y la codificación representan cada una una parte del tiempo. El GIL de Python limita el paralelismo CPU puro, pero Pillow libera el GIL cuando llama a las bibliotecas C subyacentes para la codificación JPEG/WebP, por lo que el multihilo puede aportar aceleraciones sustanciales:


import os
from pathlib import Path
from PIL import Image
from concurrent.futures import ThreadPoolExecutor, as_completed
import threading

SUPPORTED_FORMATS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff"}

_print_lock = threading.Lock()


def compress_single(input_path, output_path, quality=75, max_size=1920):
    with Image.open(input_path) as img:
        exif = img.info.get("exif", b"")
        if img.mode != "RGB":
            img = img.convert("RGB")
        if max_size and max(img.size) > max_size:
            img.thumbnail((max_size, max_size), Image.Resampling.LANCZOS)
        kwargs = {"quality": quality, "optimize": True, "progressive": True}
        if exif:
            kwargs["exif"] = exif
        img.save(output_path, format="JPEG", **kwargs)


def batch_compress_threaded(input_dir, output_dir, quality=75, max_size=1920,
                            workers=4):
    input_dir = Path(input_dir)
    output_dir = Path(output_dir)

    # Recopilar tareas
    tasks = []
    for root, _, files in os.walk(input_dir):
        for f in files:
            if Path(f).suffix.lower() in SUPPORTED_FORMATS:
                src = Path(root) / f
                rel = src.relative_to(input_dir)
                dst = output_dir / rel
                dst.parent.mkdir(parents=True, exist_ok=True)
                tasks.append((src, dst))

    total = len(tasks)
    print(f"{total} imágenes en total, procesando con {workers} hilos\n")

    completed = 0
    total_saved = 0

    with ThreadPoolExecutor(max_workers=workers) as executor:
        futures = {
            executor.submit(compress_single, src, dst, quality, max_size): (src, dst)
            for src, dst in tasks
        }
        for future in as_completed(futures):
            src, dst = futures[future]
            try:
                future.result()
                saved = src.stat().st_size - dst.stat().st_size
                total_saved += saved
            except Exception as e:
                with _print_lock:
                    print(f"Fallido: {src.name} - {e}")
            completed += 1
            with _print_lock:
                print(f"[{completed / total * 100:5.1f}%] ({completed}/{total}) {src.name}")

    print(f"\nTerminado. Espacio ahorrado: {total_saved / 1024 / 1024:.2f} MB")


if __name__ == "__main__":
    batch_compress_threaded("./photos", "./output", quality=75, max_size=1920, workers=4)

La regla empírica para el número de hilos es el número de núcleos lógicos de la CPU. En una máquina de 8 núcleos, 4-6 hilos suelen alcanzar una aceleración casi óptima. Demasiados hilos pueden degradar el rendimiento debido a la contención de E/S del disco y la presión de memoria.

Si necesitas ir más allá con el paralelismo, puedes usar ProcessPoolExecutor para eludir el GIL. El inconveniente es que los procesos no pueden compartir memoria, cada proceso debe cargar la biblioteca Pillow de forma independiente y la sobrecarga de inicio es mayor.

Gestión de memoria

El consumo de memoria se convierte en una preocupación al procesar imágenes de alta resolución. Una sola imagen RGB de 6000x4000 ocupa unos 72 MB de memoria tras la decodificación (6000x4000x3 bytes). Si se abren 10 simultáneamente, son 720 MB.

Varios principios clave de gestión de memoria:


from PIL import Image
import gc

# 1. Usar siempre la sentencia with para asegurar que los descriptores de archivo y los búferes de píxel se liberen rápidamente
def safe_compress(input_path, output_path, quality=75):
    with Image.open(input_path) as img:
        # 2. Copiar la imagen si necesitas modificarla, evitando operaciones sobre el original
        work = img.copy()
    # img se libera; work sigue en el ámbito
    if work.mode != "RGB":
        work = work.convert("RGB")
    work.save(output_path, format="JPEG", quality=quality, optimize=True)
    work.close()  # 3. Cerrar explícitamente

# 4. Disparar la recolección de basura periódicamente durante el procesamiento por lotes
def batch_with_gc(tasks, interval=100):
    for i, task in enumerate(tasks):
        process(task)
        if i % interval == 0:
            gc.collect()

Además, Image.MAX_IMAGE_PIXELS tiene por defecto un límite de aproximadamente 178 millones de píxeles (unos 8900x8900). Superarlo lanza un DecompressionBombError. Si realmente necesitas procesar imágenes muy grandes, puedes elevar este umbral, pero ten en cuenta el consumo de memoria:


Image.MAX_IMAGE_PIXELS = None  # Desactivar el límite (usar con precaución)

Comparación de resultados de compresión

La tabla siguiente muestra datos medidos de un conjunto de 50 fotos de smartphone (promedio original 6,5 MB, 4000x3000 píxeles). Todas las pruebas se ejecutaron en el mismo hardware, con la resolución reducida uniformemente a un lado más largo de 1920:

Factor de calidadFormatoTamaño medioRatioCalidad subjetivaCaso de uso
OriginalJPEG6,5 MB1,0xReferencia
95JPEG1,8 MB3,6xPrácticamente idénticaArchivado de alta calidad
85JPEG0,92 MB7,1xDiferencia imperceptibleImágenes destacadas de sitio web
75JPEG0,58 MB11,2xLigeros artefactos en inspección cercanaMiniaturas, páginas de listado
65JPEG0,42 MB15,5xArtefactos en bloques visiblesNo recomendado
50JPEG0,28 MB23,2xDistorsión evidenteSolo vistas previas diminutas
80WebP0,51 MB12,7xComparable a JPEG 85Sitios web modernos
Sin pérdidaWebP4,2 MB1,5xPerfectamente sin pérdidaCuando se necesita transparencia

Varios patrones emergen de los datos:

  • La calidad 85 es el punto de inflexión costo-beneficio: ahorra casi la mitad del tamaño frente a 95, con diferencias de calidad prácticamente imperceptibles para el ojo.
  • Por debajo de la calidad 70, los rendimientos disminuyen rápidamente: el tamaño del archivo sigue disminuyendo, pero la degradación de la calidad se acelera notablemente.
  • Los factores de calidad WebP no son directamente equivalentes a JPEG: WebP 80 produce una calidad comparable a JPEG 85 pero con un tamaño menor.
  • El WebP sin pérdida solo tiene sentido cuando se requiere transparencia; para fotografías, la ventaja de tamaño es mínima.

Preguntas frecuentes

P1: ¿Por qué las imágenes aparecen al revés o de lado después de la compresión?

Esto ocurre porque la etiqueta de orientación EXIF del smartphone se descarta durante la compresión, mientras que los datos de píxel reales se almacenaron en orientación horizontal. La solución es llamar a ImageOps.exif_transpose(img) antes de guardar. Rota los datos de píxel según la etiqueta de orientación EXIF para que la imagen esté correctamente orientada antes de ser guardada. De este modo, incluso si el software posterior no lee la información de orientación EXIF, la imagen se mostrará correctamente.

P2: ¿Por qué el uso de memoria sigue creciendo al procesar 1000 imágenes?

La causa más común es no cerrar los objetos de imagen con prontitud. Usa siempre el gestor de contexto with Image.open(...) as img: para asegurar que cada imagen se libere inmediatamente tras el procesamiento. En segundo lugar, en la versión multihilo, si todas las tareas se envían a la vez, los objetos future mantienen referencias a los resultados hasta que se consumen, lo que provoca acumulación de memoria. La solución es enviar las tareas por lotes usando chunksize, o cambiar a un patrón de cola productor-consumidor para controlar la concurrencia. Por último, llamar a gc.collect() periódicamente puede reclamar la memoria retenida por referencias circulares.

P3: ¿Por qué las zonas transparentes se vuelven negras después de convertir PNG a JPEG?

El formato JPEG no admite transparencia. Cuando Pillow convierte RGBA a RGB por defecto, rellena el fondo transparente con negro. La solución es crear primero una imagen RGB con fondo blanco y luego usar paste() para superponer la imagen original con el canal alfa como máscara:


background = Image.new("RGB", img.size, (255, 255, 255))
background.paste(img, mask=img.split()[3])  # El 4.º canal es el alfa

Alternativamente, convierte directamente a WebP, que admite transparencia de forma nativa sin ningún manejo adicional.

P4: ¿Por qué el multihilo no ofrece una aceleración lineal?

La compresión de imágenes implica tanto E/S de disco como codificación de CPU. Una vez que el número de hilos supera cierto punto, el ancho de banda del disco se convierte en el cuello de botella: la CPU no puede ir más rápido de lo que los datos pueden leerse y escribirse. Además, los datos de píxel decodificados de las imágenes de alta resolución son grandes, y procesar varias imágenes grandes simultáneamente en diferentes hilos provoca contención del ancho de banda de memoria y tasas de acierto de caché reducidas. En la práctica, 4-8 hilos suelen ofrecer una aceleración de 2-4x, con rendimientos decrecientes más allá. Para discos duros mecánicos, se recomiendan 4 hilos; para SSD NVMe, el número puede aumentarse algo.


Resumen

La compresión por lotes de imágenes con Python Pillow puede descomponerse en tres capas:

  • Capa base: Domina los tres métodos Image.open(), thumbnail() y save(), combinados con los parámetros quality y optimize, y podrás comprimir una sola imagen. Este es el bloque de construcción de todo el procesamiento por lotes.
  • Capa de ingeniería: Usa os.walk() para recorrer directorios, Path.relative_to() para preservar la estructura y concurrent.futures para el procesamiento paralelo a fin de escalar la lógica de una sola imagen a miles de imágenes. Esta capa se centra en la corrección, la robustez y el rendimiento.
  • Capa de optimización: Selecciona inteligentemente formatos y parámetros según las características de la imagen: las imágenes transparentes van a WebP, las imágenes grandes se reducen primero, las imágenes pequeñas se mantienen como PNG. Detalles como la preservación EXIF, la corrección de orientación y la gestión de memoria determinan si el script puede ejecutarse de forma fiable en un entorno de producción.

Existe una regla empírica para la selección del factor de calidad: 85 es el umbral de calidad visualmente sin pérdida, 75 es el punto de equilibrio entre tamaño y calidad, y por debajo de 65 no se recomienda para visualización. WebP ya es totalmente compatible con los navegadores modernos y debería ser el formato preferido para proyectos nuevos.

El valor de este script no reside en reemplazar herramientas profesionales sino en su personalización. Puedes ajustar cada etapa según tus necesidades reales: configurar parámetros diferentes para directorios específicos, subir automáticamente a un CDN tras la compresión o integrarlo en un pipeline CI/CD. Comprender los principios detrás de cada línea de código te da la base para manejar cualquier escenario de procesamiento de imágenes.

Lecturas relacionadas:

¿Necesita comprimir archivos? Pruebe SmartSlim

Basado en un motor de compresión Rust de desarrollo propio, soporta más de 40 formatos en 10 categorías como PDF, imágenes, vídeo, Office y OFD. La compresión se realiza localmente y los datos no salen de su dominio.