Por qué GIF sigue siendo relevante
A pesar de tener más de 35 años (se creó en 1987), GIF sigue siendo el formato preferido para animaciones breves en redes sociales, documentación técnica y comunicación visual. Sus ventajas únicas:
- Reproducción automática en Twitter/X, Slack, Discord, GitHub, correo electrónico
- Sin controles de reproducción — bucle silencioso e invisible
- Soporte universal — funciona en cualquier navegador, cliente de correo y app
- Tamaño manejable para clips cortos de 2-5 segundos
Sus limitaciones: máximo 256 colores por fotograma, sin audio, compresión LZW eficiente solo en imágenes con pocos colores.
Instalación
pip install Pillow imageio imageio-ffmpeg numpy
Crear GIF desde imágenes con Pillow
from PIL import Image
from pathlib import Path
import os
def imagenes_a_gif(carpeta_o_lista, salida_gif, duracion_ms=100, bucle=0):
"""
Crea un GIF animado desde una carpeta o lista de imágenes.
Args:
carpeta_o_lista: ruta a carpeta o lista de rutas de imágenes
salida_gif: ruta del GIF resultante
duracion_ms: duración de cada fotograma en milisegundos
bucle: 0 = infinito, 1 = una vez, N = N veces
"""
# Obtener lista de archivos
if isinstance(carpeta_o_lista, (str, Path)):
carpeta = Path(carpeta_o_lista)
extensiones = {'.jpg', '.jpeg', '.png', '.webp', '.bmp'}
archivos = sorted([
f for f in carpeta.iterdir()
if f.suffix.lower() in extensiones
], key=lambda p: p.stem)
else:
archivos = [Path(f) for f in carpeta_o_lista]
if not archivos:
raise ValueError("No se encontraron imágenes")
print(f"Creando GIF con {len(archivos)} fotogramas...")
# Cargar y convertir a paleta optimizada
fotogramas = []
primer_frame = Image.open(archivos[0]).convert('RGBA')
ancho, alto = primer_frame.size
for archivo in archivos:
img = Image.open(archivo).convert('RGBA')
# Redimensionar si no tienen el mismo tamaño
if img.size != (ancho, alto):
img = img.resize((ancho, alto), Image.LANCZOS)
# Convertir a paleta P con dithering para mejor calidad
img_p = img.convert('P', palette=Image.ADAPTIVE, colors=256)
fotogramas.append(img_p)
# Guardar GIF
fotogramas[0].save(
salida_gif,
save_all=True,
append_images=fotogramas[1:],
duration=duracion_ms,
loop=bucle,
optimize=True,
disposal=2 # 2 = restaurar fondo entre fotogramas
)
tamaño = Path(salida_gif).stat().st_size / 1024
print(f"GIF creado: {salida_gif} ({tamaño:.1f} KB, {len(fotogramas)} frames)")
return str(salida_gif)
# Ejemplo: animación de carga
imagenes_a_gif('capturas/', 'demo.gif', duracion_ms=80)
Crear GIF animado programáticamente
from PIL import Image, ImageDraw, ImageFont
import math
def crear_gif_loader(salida='loader.gif', tamaño=100, frames=20):
"""Crea un GIF de carga circular animado."""
fotogramas = []
for i in range(frames):
img = Image.new('RGBA', (tamaño, tamaño), (255, 255, 255, 0))
d = ImageDraw.Draw(img)
cx, cy = tamaño // 2, tamaño // 2
radio = tamaño // 2 - 8
# Dibujar arco rotando en cada fotograma
angulo_inicio = (i / frames) * 360
angulo_fin = angulo_inicio + 270
# Fondo gris claro
d.ellipse([cx - radio, cy - radio, cx + radio, cy + radio],
outline=(220, 220, 220), width=6)
# Arco activo (azul)
d.arc([cx - radio, cy - radio, cx + radio, cy + radio],
start=angulo_inicio, end=angulo_fin,
fill=(67, 97, 238), width=6)
fotogramas.append(img.convert('P', palette=Image.ADAPTIVE, colors=256))
fotogramas[0].save(
salida, save_all=True, append_images=fotogramas[1:],
duration=50, loop=0, optimize=True
)
print(f"Loader GIF: {salida} ({frames} frames)")
def crear_gif_texto(texto, salida='texto.gif', frames_por_char=8):
"""Crea un GIF de texto que aparece letra a letra."""
fuente_tam = 36
relleno = 20
# Medir el ancho final del texto
img_tmp = Image.new('RGB', (1, 1))
d_tmp = ImageDraw.Draw(img_tmp)
bbox = d_tmp.textbbox((0, 0), texto, font_size=fuente_tam)
ancho = bbox[2] - bbox[0] + relleno * 2
alto = bbox[3] - bbox[1] + relleno * 2
fotogramas = []
for n in range(1, len(texto) + 1):
img = Image.new('RGB', (ancho, alto), color='#1a1a2e')
d = ImageDraw.Draw(img)
d.text((relleno, relleno), texto[:n], fill='#4cc9f0',
font_size=fuente_tam)
for _ in range(frames_por_char):
fotogramas.append(img.convert('P', palette=Image.ADAPTIVE, colors=64))
fotogramas[0].save(
salida, save_all=True, append_images=fotogramas[1:],
duration=80, loop=0, optimize=True
)
print(f"Texto GIF: {salida}")
def crear_gif_onda(salida='onda.gif', ancho=400, alto=200, frames=30):
"""Crea un GIF de onda sinusoidal animada."""
import numpy as np
fotogramas = []
x = np.linspace(0, 4 * math.pi, ancho)
for i in range(frames):
img = Image.new('RGB', (ancho, alto), '#0d1b2a')
d = ImageDraw.Draw(img)
fase = (i / frames) * 2 * math.pi
puntos = []
for j, xi in enumerate(x):
y = alto // 2 + int((alto // 3) * math.sin(xi + fase))
puntos.append((j, y))
d.line(puntos, fill='#4cc9f0', width=3)
# Segunda onda desfasada
puntos2 = []
for j, xi in enumerate(x):
y = alto // 2 + int((alto // 4) * math.sin(xi + fase + math.pi / 2))
puntos2.append((j, y))
d.line(puntos2, fill='#f72585', width=2)
fotogramas.append(img.convert('P', palette=Image.ADAPTIVE, colors=64))
fotogramas[0].save(
salida, save_all=True, append_images=fotogramas[1:],
duration=40, loop=0, optimize=True
)
print(f"Onda GIF: {salida}")
# Crear los GIFs de ejemplo
crear_gif_loader('loader.gif')
crear_gif_texto('KaijuConverter', 'titulo.gif')
crear_gif_onda('onda.gif')
Convertir vídeo a GIF con FFmpeg
import subprocess
import shutil
def video_a_gif(video_entrada, gif_salida, fps=15, ancho=480,
inicio=None, duracion=None, paleta=True):
"""
Convierte un vídeo a GIF optimizado con FFmpeg.
Usa paleta de 2 pasadas para la máxima calidad de color.
Args:
fps: fotogramas por segundo del GIF (8-15 es lo normal)
ancho: ancho en píxeles (alto se calcula automáticamente)
inicio: timestamp de inicio en segundos (o "00:01:30")
duracion: duración en segundos
paleta: True = 2 pasadas con paleta optimizada (mejor calidad)
"""
if not shutil.which('ffmpeg'):
raise RuntimeError("FFmpeg no está instalado")
# Filtros de vídeo
filtro_escala = f'scale={ancho}:-1:flags=lanczos'
filtro_fps = f'fps={fps}'
# Opciones de tiempo
opciones_tiempo = []
if inicio:
opciones_tiempo += ['-ss', str(inicio)]
if duracion:
opciones_tiempo += ['-t', str(duracion)]
if paleta:
# Paso 1: generar paleta de colores óptima
paleta_path = gif_salida.replace('.gif', '_palette.png')
filtro_paleta = f'{filtro_fps},{filtro_escala},palettegen=max_colors=256:stats_mode=diff'
cmd_paleta = (
['ffmpeg', '-y'] + opciones_tiempo +
['-i', video_entrada,
'-vf', filtro_paleta,
paleta_path]
)
subprocess.run(cmd_paleta, capture_output=True, check=True)
# Paso 2: aplicar paleta al GIF
filtro_uso = f'{filtro_fps},{filtro_escala}[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle'
cmd_gif = (
['ffmpeg', '-y'] + opciones_tiempo +
['-i', video_entrada,
'-i', paleta_path,
'-lavfi', filtro_uso,
gif_salida]
)
subprocess.run(cmd_gif, capture_output=True, check=True)
# Limpiar paleta temporal
import os
os.unlink(paleta_path)
else:
# Conversión directa (más rápida, menor calidad)
filtro = f'{filtro_fps},{filtro_escala}'
cmd = (
['ffmpeg', '-y'] + opciones_tiempo +
['-i', video_entrada,
'-vf', filtro,
gif_salida]
)
subprocess.run(cmd, capture_output=True, check=True)
tamaño = Path(gif_salida).stat().st_size / 1024 / 1024
print(f"GIF creado: {gif_salida} ({tamaño:.2f} MB)")
return gif_salida
# Ejemplos
video_a_gif('tutorial.mp4', 'demo.gif', fps=12, ancho=600)
video_a_gif('pelicula.mp4', 'escena.gif', fps=10, ancho=480,
inicio='00:01:30', duracion=4, paleta=True)
Optimizar GIFs existentes con gifsicle
def optimizar_gif(entrada, salida=None, nivel=3, ancho=None):
"""
Optimiza un GIF con gifsicle (pip install gifsicle o apt install gifsicle).
nivel: 1 (rápido) a 3 (máxima compresión)
"""
if not shutil.which('gifsicle'):
# Fallback: re-guardar con Pillow
print("gifsicle no disponible — usando Pillow para reoptimizar")
img = Image.open(entrada)
frames = []
try:
while True:
frames.append(img.copy().convert('P',
palette=Image.ADAPTIVE, colors=256))
img.seek(img.tell() + 1)
except EOFError:
pass
dest = salida or entrada
frames[0].save(dest, save_all=True, append_images=frames[1:],
duration=img.info.get('duration', 100),
loop=0, optimize=True)
return
if salida is None:
salida = entrada.replace('.gif', '_opt.gif')
cmd = ['gifsicle', f'-O{nivel}', '--colors', '256']
if ancho:
cmd += ['--resize-width', str(ancho)]
cmd += [entrada, '-o', salida]
result = subprocess.run(cmd, capture_output=True)
if result.returncode == 0:
orig = Path(entrada).stat().st_size / 1024
nuevo = Path(salida).stat().st_size / 1024
pct = (1 - nuevo / orig) * 100
print(f"Optimizado: {pct:.1f}% de reducción ({orig:.0f} KB → {nuevo:.0f} KB)")
else:
print(f"Error gifsicle: {result.stderr.decode()}")
def extraer_frames_gif(ruta_gif, carpeta_salida):
"""Extrae todos los fotogramas de un GIF como imágenes individuales."""
carpeta = Path(carpeta_salida)
carpeta.mkdir(parents=True, exist_ok=True)
img = Image.open(ruta_gif)
frame_count = 0
try:
while True:
img.save(carpeta / f'frame_{frame_count:04d}.png')
frame_count += 1
img.seek(img.tell() + 1)
except EOFError:
pass
print(f"Extraídos {frame_count} fotogramas en {carpeta_salida}")
return frame_count
Generar GIF desde matplotlib
import matplotlib.pyplot as plt
import matplotlib.animation as animation
import numpy as np
def grafica_animada_a_gif(salida='grafica.gif', frames=60, fps=20):
"""Genera un GIF de una función matemática animada."""
fig, ax = plt.subplots(figsize=(8, 4), facecolor='#0d1b2a')
ax.set_facecolor('#0d1b2a')
ax.tick_params(colors='white')
ax.spines[:].set_color('#4361ee')
x = np.linspace(0, 4 * np.pi, 400)
line, = ax.plot(x, np.sin(x), color='#4cc9f0', linewidth=2)
ax.set_ylim(-1.5, 1.5)
ax.set_xlim(0, 4 * np.pi)
ax.set_title('sin(x + fase)', color='white', fontsize=14)
def update(frame):
fase = (frame / frames) * 2 * np.pi
line.set_ydata(np.sin(x + fase))
return (line,)
ani = animation.FuncAnimation(fig, update, frames=frames, blit=True)
writer = animation.PillowWriter(fps=fps)
ani.save(salida, writer=writer, dpi=100)
plt.close(fig)
print(f"GIF matplotlib: {salida}")
grafica_animada_a_gif('seno_animado.gif')
Cuándo usar GIF vs alternativas
| Formato | Animación | Colores | Transparencia | Soporte email | Tamaño |
|---|---|---|---|---|---|
| GIF | ✅ | 256 | ✅ 1-bit | ✅ Universal | ⚠️ Grande |
| APNG | ✅ | 16M | ✅ Alpha | ⚠️ No Outlook | Menor |
| WebP | ✅ | 16M | ✅ Alpha | ❌ Limitado | Mucho menor |
| MP4 | ✅ | 16M | ❌ | ❌ | Más pequeño |
| Lottie | ✅ | Vectorial | ✅ | ❌ | Más pequeño |
Regla práctica: usa GIF para compatibilidad universal (email, Slack, Discord). Para web moderno, WebP animado es hasta 4× más pequeño con mejor calidad.
Conclusión
Python ofrece tres caminos para crear GIFs: Pillow para animaciones programáticas simples, imageio para flujos de procesamiento, y FFmpeg para la mayor calidad al convertir vídeo. El truco clave para GIFs de alta calidad desde vídeo es la generación de paleta en dos pasadas con FFmpeg: detecta los colores dominantes de toda la secuencia antes de codificar, reduciendo el banding de color drásticamente.
Conversiones relacionadas
Lo más habitual al trabajar con imágenes son estas direcciones de conversión: