Convertir HEIC a JPG con Python: script probado (y sin perder EXIF)

Convertir HEIC a JPG con Python: script probado (y sin perder EXIF)

Hace un par de años, trabajando en una empresa en Barranquilla, uno de mis primeros retos fue recopilar información de distintas fuentes de la planta y de sus equipos. Entre esas fuentes había muchas imágenes, y buena parte venía en HEIC, el formato que usan por defecto los iPhone. El problema: Windows no las abría.

Pensé en usar un convertidor en línea, gratuito o de pago, pero lo descarté sin llegar a probarlo, por dos razones: la privacidad de la información, porque eran fotos de la planta y sus equipos, y la cantidad de imágenes, que era considerable. Así que escribí un pequeño script con Python, Pillow, pillow-heif y pathlib.

Ese script cumplió su función, pero mirado con calma tiene varios puntos débiles. Aquí tienes dos versiones mejoradas: un script corto para salir del apuro y uno completo para procesar carpetas enteras, ambos probados.

[!NOTE] Respuesta rápida: instala pillow y pillow-heif, llama a register_heif_opener() una vez y abre cada archivo con Image.open(). Guarda con .save("foto.jpg", quality=90, exif=...) para conservar la fecha y los datos de la cámara. Compara la extensión sin distinguir mayúsculas: las fotos de iPhone suelen llamarse IMG_1234.HEIC.


La solución rápida: un script corto

Instala las dependencias (conviene hacerlo dentro de un entorno virtual):

pip install pillow pillow-heif

Y guarda esto como heic_to_jpg_minimal.py:

import sys
from pathlib import Path

from PIL import Image
from pillow_heif import register_heif_opener

register_heif_opener()  # lets Pillow open .heic / .heif files

for path in Path(sys.argv[1]).iterdir():
    if path.suffix.lower() in {".heic", ".heif", ".hif"}:  # .HEIC, .Heic, .heif...
        try:
            with Image.open(path) as image:
                exif = image.info.get("exif") or b""  # keeps date, camera, GPS...
                image.convert("RGB").save(path.with_suffix(".jpg"), quality=90, exif=exif)
            print("OK  ", path.name)
        except Exception as error:  # a corrupt file must not stop the rest
            print("FAIL", path.name, error)

Se usa pasándole la carpeta con las fotos:

python heic_to_jpg_minimal.py ./fotos

Cada .heic, .heif o .hif (en mayúsculas o minúsculas) queda convertido a .jpg al lado del original, con calidad 90 y el EXIF intacto. Si un archivo está dañado, lo reporta y sigue con el resto.

[!NOTE] Versiones probadas: Python 3.13.5, Pillow 12.3.0 y pillow-heif 1.8.1 (que trae libheif 1.23.6 incluido en el paquete), en Linux. pillow-heif publica ruedas ya compiladas para Windows, macOS y Linux, así que normalmente no necesitas instalar nada más.


Por qué falla el script típico

Si buscas “python heic to jpg” encontrarás decenas de ejemplos casi idénticos, y el mío original era uno de ellos: listar la carpeta, filtrar los archivos que terminan en .heic, abrirlos y guardarlos como JPG. Funciona en la demo y se rompe con fotos reales por cuatro razones.

1. Solo reconoce .heic en minúsculas

Un filtro como str(f).endswith(".heic") ignora IMG_0001.HEIC, que es justo como las nombra el iPhone. El resultado es el peor tipo de falla: el script termina sin errores y sin convertir nada. La solución es comparar con path.suffix.lower() e incluir .heif y .hif, otras extensiones del mismo contenedor.

2. Pierde el EXIF (fecha, cámara, GPS)

Image.save() no copia los metadatos por sí solo: hay que pasárselos con el parámetro exif. Sin él, el JPG resultante no tiene fecha de captura ni modelo de cámara, y tu galería lo ordenará por fecha de modificación del archivo. En mi caso eso importaba: la fecha de la foto era parte de la información que estaba recopilando.

3. Calidad por defecto

Según la documentación de Pillow, el valor por defecto de quality para JPEG es 75. No es terrible, pero para fotos que vas a archivar o inspeccionar con zoom conviene subirlo. Pillow recomienda no pasar de 95; 90 es un buen punto de equilibrio.

4. Un archivo dañado detiene todo el lote

Si procesas 400 fotos y la 237 está corrupta, un script sin try/except aborta ahí y te deja con la mitad hecha. En los scripts de este artículo cada archivo se procesa de forma aislada y los fallos se reportan al final.


Script completo: convertir carpetas enteras

Si tienes muchas fotos y subcarpetas, esta es la versión que te conviene. Es una herramienta de línea de comandos con argparse: admite recursividad, carpeta de salida, JPG/WebP/PNG, calidad, quitar GPS y procesamiento en paralelo. Guárdala como heic_convert.py:

import argparse
import os
import sys
from concurrent.futures import ProcessPoolExecutor
from pathlib import Path

from PIL import Image
from pillow_heif import register_heif_opener

HEIF_SUFFIXES = {".heic", ".heif", ".hif"}
GPS_IFD = 0x8825  # EXIF tag that points to the GPS block


def find_images(folder: Path, recursive: bool) -> list[Path]:
    pattern = "**/*" if recursive else "*"
    return sorted(p for p in folder.glob(pattern) if p.suffix.lower() in HEIF_SUFFIXES)


def build_exif(image: Image.Image, strip_gps: bool) -> bytes:
    exif = image.getexif()
    if not exif:
        return b""
    if strip_gps and GPS_IFD in exif:
        del exif[GPS_IFD]
    return exif.tobytes()


def convert_one(src: Path, dst: Path, fmt: str, quality: int, strip_gps: bool) -> None:
    register_heif_opener()  # each worker process needs its own registration
    with Image.open(src) as image:  # orientation is already applied on open
        exif = build_exif(image, strip_gps)
        if fmt == "jpg" and image.mode != "RGB":
            image = image.convert("RGB")  # JPEG has no alpha channel
        pil_format = {"jpg": "JPEG", "webp": "WEBP", "png": "PNG"}[fmt]
        image.save(dst, format=pil_format, quality=quality, exif=exif)


def run_job(job: tuple) -> tuple[str, Path, str]:
    src, dst, fmt, quality, strip_gps, overwrite = job
    if dst.exists() and not overwrite:
        return "skipped", src, "already exists"
    try:
        dst.parent.mkdir(parents=True, exist_ok=True)
        convert_one(src, dst, fmt, quality, strip_gps)
        return "converted", src, ""
    except Exception as error:  # one bad file must not stop the batch
        return "failed", src, f"{type(error).__name__}: {error}"


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Convert HEIC/HEIF photos to JPG, WebP or PNG.")
    parser.add_argument("input", type=Path, help="folder with HEIC/HEIF files")
    parser.add_argument("--output", type=Path, help="output folder (default: next to each source file)")
    parser.add_argument("--recursive", action="store_true", help="also process subfolders")
    parser.add_argument("--format", choices=["jpg", "webp", "png"], default="jpg")
    parser.add_argument("--quality", type=int, default=90, help="1-100, ignored for PNG")
    parser.add_argument("--strip-gps", action="store_true", help="remove GPS location, keep the rest of EXIF")
    parser.add_argument("--workers", type=int, default=os.cpu_count(), help="parallel processes")
    parser.add_argument("--overwrite", action="store_true", help="replace existing output files")
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    if not args.input.is_dir():
        sys.exit(f"Not a folder: {args.input}")
    images = find_images(args.input, args.recursive)
    jobs = []
    for src in images:
        base = args.output / src.relative_to(args.input) if args.output else src
        dst = base.with_suffix(f".{args.format}")
        jobs.append((src, dst, args.format, args.quality, args.strip_gps, args.overwrite))

    counts = {"converted": 0, "skipped": 0, "failed": 0}
    with ProcessPoolExecutor(max_workers=args.workers) as pool:
        for status, src, detail in pool.map(run_job, jobs):
            counts[status] += 1
            print(f"{status.upper():9} {src} {detail}".rstrip())

    print(f"\nDone: {counts['converted']} converted, {counts['skipped']} skipped, {counts['failed']} failed")
    return 1 if counts["failed"] else 0


if __name__ == "__main__":
    sys.exit(main())

Algunos ejemplos de uso:

# Convertir una carpeta (los JPG quedan junto a los originales)
python heic_convert.py ./fotos

# Incluir subcarpetas y guardar en otra carpeta, respetando la estructura
python heic_convert.py ./fotos --recursive --output ./convertidas

# WebP con calidad 80 para publicar en la web
python heic_convert.py ./fotos --format webp --quality 80 --output ./web

# Quitar la ubicación GPS y usar 4 procesos en paralelo
python heic_convert.py ./fotos --recursive --strip-gps --workers 4

# Reconvertir reemplazando archivos que ya existen
python heic_convert.py ./fotos --overwrite

Al terminar imprime un resumen (Done: 5 converted, 0 skipped, 1 failed) y devuelve un código de salida distinto de cero si algo falló, lo que sirve si lo llamas desde otro script o desde una tarea programada.

Decisiones de diseño que vale la pena explicar

  • No hace falta ImageOps.exif_transpose(). Al abrir un HEIC con el plugin, pillow-heif entrega los píxeles ya con la rotación aplicada y pone la etiqueta de orientación del EXIF en 1, para que nadie rote la imagen dos veces. Lo comprobé con un archivo real con rotación de 90° (guitar_cw90.hif, del repositorio de pruebas de pillow-heif): el JPG salió en vertical y correcto.
  • Salta lo que ya existe. Si vuelves a ejecutar el script, no rehace el trabajo; usa --overwrite cuando sí quieras reemplazar.
  • RGBA a RGB solo para JPG. JPG no soporta transparencia, así que si el HEIC tiene canal alfa se convierte a RGB antes de guardar (se descarta el canal alfa). WebP y PNG conservan la transparencia.
  • register_heif_opener() dentro de cada proceso. Con ProcessPoolExecutor cada proceso hijo necesita registrar el plugin; en Windows y macOS los procesos se crean desde cero y no heredan el registro.
  • Un try/except por archivo. Los errores se devuelven como resultado en vez de propagarse, así un archivo dañado no mata el proceso hijo ni el lote.

[!WARNING] Nombres duplicados: si en una carpeta tienes foto.heic y foto.heif, ambos se convierten a foto.jpg y el segundo se salta como “ya existe”. Es raro, pero si pasa, renombra uno.


Privacidad: convierte local y quita el GPS

Mi razón original para no usar convertidores en línea fue la privacidad, y sigue siendo la mejor. Al subir una foto a un servicio web, ese servicio recibe tu archivo completo, incluidos los metadatos: fecha, modelo del teléfono y, si el GPS estaba activo, las coordenadas exactas de dónde la tomaste. Con fotos de una planta industrial, de un documento o de tu casa, no quieres regalar eso. Con Python el archivo nunca sale de tu equipo.

El otro lado de la moneda es que los metadatos viajan con la foto convertida. Si vas a compartir o publicar las imágenes, quítale la ubicación:

python heic_convert.py ./fotos --recursive --strip-gps --output ./para-compartir

--strip-gps elimina únicamente el bloque GPS del EXIF y conserva el resto (fecha, cámara, etc.). Lo verifiqué abriendo el JPG resultante: con la opción, el bloque GPS queda vacío y la fecha DateTimeOriginal sigue ahí; sin la opción, ambos se conservan.

[!WARNING] --strip-gps no limpia todo. Otras partes del EXIF pueden identificarte (por ejemplo el modelo del dispositivo), y la imagen en sí puede mostrar datos sensibles. Si necesitas anonimato real, elimina todo el EXIF: basta con pasar exif=b"" al guardar.


Convertir a WebP para la web

Si el destino de las fotos es una página web y no un archivo personal, JPG no es el mejor formato. WebP suele pesar bastante menos con calidad visual comparable, y menos peso en imágenes ayuda al LCP (Largest Contentful Paint), una de las métricas de Core Web Vitals que explico en la guía de SEO técnico.

Con el script es un solo parámetro. En una foto real de prueba de 4672 × 7008 píxeles (5,2 MB en HEIC), con calidad 90 obtuve:

Tabla 1: Tamaño del archivo según formato

FormatoTamaño
HEIC original5,2 MB
JPG2,9 MB
WebP1,1 MB
PNG30 MB

Es una sola imagen, no un benchmark, y el resultado depende de la foto y de la calidad elegida. Pero ilustra bien por qué PNG no es buena idea para fotografías: úsalo para capturas, logos o cuando necesites transparencia sin pérdida.

Para una web real, además de convertir, redimensiona: una foto de 4672 píxeles de ancho es excesiva para un artículo de blog.


Sin Python

Si solo necesitas convertir unas pocas fotos una vez, quizá no valga la pena escribir código:

  • Windows: instala HEIF Image Extensions desde Microsoft Store. Según la ficha, para archivos .heic también puede hacer falta el paquete HEVC Video Extensions. Ojo: esto sirve para ver las fotos en el Explorador y la app Fotos, no para convertirlas.
  • macOS: sips viene con el sistema. El manual documenta la sintaxis sips -s format jpeg entrada.heic --out salida.jpg y la opción -s formatOptions para la calidad. Nota de honestidad: no lo probé en un Mac.
  • Linux: heif-convert, del paquete libheif-examples (en Debian): heif-convert -q 90 entrada.heic salida.jpg. El formato de salida se deduce de la extensión (JPG, PNG o Y4M) y -q define la calidad de 0 a 100.
  • ImageMagick: magick entrada.heic salida.jpg. Según su lista de formatos, el soporte de HEIC requiere la biblioteca libheif, así que depende de cómo esté compilado tu ImageMagick.

Todas convierten archivo por archivo o necesitan un bucle de shell. Para carpetas completas, con EXIF controlado y reporte de errores, Python sigue siendo más cómodo.


Errores comunes

«cannot identify image file» (UnidentifiedImageError)

Casi siempre es una de dos cosas: no instalaste pillow-heif, o no llamaste a register_heif_opener() antes de Image.open(). Sin el registro, Pillow no sabe leer HEIC y lanza UnidentifiedImageError; lo comprobé abriendo un .heic sin el plugin. Si lo tienes registrado y el error persiste, el archivo puede estar realmente dañado o ser una descarga incompleta (en mis pruebas, un archivo con bytes aleatorios produce exactamente este mensaje).

El script termina y no convierte nada

Revisa la extensión. Si tus archivos son .HEIC y el filtro compara con ".heic" en minúsculas, no encuentra ninguno. Usa path.suffix.lower(), como en los scripts de arriba. También verifica que la ruta sea la correcta: una carpeta sin archivos coincidentes no produce error.

La foto sale rotada

Con pillow-heif en modo plugin no debería pasar: aplica la rotación al abrir y reinicia la etiqueta de orientación para evitar la doble rotación, como lo documenta el proyecto. Si te ocurre, revisa que no estés abriendo el archivo con la API de bajo nivel open_heif(), donde la etiqueta no se reinicia automáticamente, o que no apliques otra rotación por tu cuenta.

Pierdo la fecha de la foto

Estás guardando sin pasar exif. Obtén los bytes con image.info.get("exif") (o con image.getexif().tobytes()) y pásalos a save(..., exif=...). Ojo con un detalle que me hizo perder un rato: en fotos sin EXIF la clave existe pero vale None, y pasar exif=None en el guardado a JPEG falló en mis pruebas con Pillow 12.3 (object of type 'NoneType' has no len()). Por eso los scripts usan or b"".

¿Pillow abre HEIC sin plugin?

No, al menos en la versión que probé (12.3.0): sin pillow-heif y sin register_heif_opener(), abrir un .heic lanza UnidentifiedImageError.

¿Cómo convierto a PNG o WebP?

Con el script completo: --format png o --format webp. Si usas el corto, cambia la extensión en with_suffix(".webp") y el formato se deduce de ella. Para PNG el parámetro quality se ignora, y los dos formatos conservan la transparencia si la hay.


En resumen

  • pillow-heif + register_heif_opener() es todo lo que Pillow necesita para leer HEIC, sin servicios externos.
  • Extensión sin distinguir mayúsculas, EXIF explícito, calidad 90 y try/except por archivo son los cuatro arreglos que separan un script de demo de uno que aguanta fotos reales.
  • Convierte local y, si vas a compartir, quita el GPS.
  • Para web, usa WebP y redimensiona.

Convertir fotos es un caso pequeño de algo más grande: casi cualquier tarea repetitiva que haces a mano con archivos se puede automatizar con un script de unas decenas de líneas. Si en tu empresa hay procesos así (renombrar, convertir, ordenar, extraer datos de cientos de archivos) y quieres que te ayude a automatizarlos, escríbeme a contact@camilordz.com.


Fuentes y lecturas recomendadas

Ideas, sistemas y lecturas de diseño web que merecen ser probadas

EXPLORAR SILO COMPLETO→