Módulo 3 · Lección 44 de 50
Imágenes con PhotoImage y Pillow
La imagen que desaparece sin dar ningún error, los formatos que Tk sí y no entiende (con una sorpresa en Tk 9), el escalado que solo admite enteros, y la trampa que Pillow añade encima.
Poner una imagen en una ventana son dos líneas. El problema es que una de esas dos líneas tiene la trampa más conocida de Tkinter, y la forma en que falla es la peor posible: no hay error, no hay excepción, no hay aviso en la consola. Solo un hueco donde iba la imagen.
Esta lección empieza por ahí, porque hasta que eso no se entiende no se puede trabajar con imágenes. Después vienen los formatos, donde hay una sorpresa agradable si tu Tk es el 9, y el escalado, donde Tk se queda muy corto y aparece la razón de instalar Pillow. Y Pillow, ya que estamos, tiene la misma trampa con un giro más.
La imagen que desaparece#
El código parece impecable:
def crear_mal(padre):
"""La imagen es local: al volver, nadie la referencia."""
imagen = tk.PhotoImage(file="foto.png")
return ttk.Label(padre, image=imagen)Y el resultado es una etiqueta vacía. Lo medido:
>>> etiqueta = crear_mal(ventana)
>>> etiqueta.cget("image")
('pyimage14',)
>>> gc.collect()
>>> etiqueta.cget("image")
('pyimage14',)
>>> ventana.tk.call("image", "width", "pyimage14")
TclError: image "pyimage14" does not exist
>>> etiqueta.winfo_exists()
TrueFíjate en lo cruel del detalle: la etiqueta sigue diciendo que tiene la imagen pyimage14. Preguntarle al widget no delata nada. Solo preguntando al intérprete de Tcl se descubre que la imagen ya no está.
La razón es la misma que la de las variables de control de la lección 33, y es el mismo hallazgo con otra cara: tk.PhotoImage no guarda los píxeles, guarda el nombre de una imagen que vive en el intérprete de Tcl. Y el destructor del objeto Python borra la imagen de Tcl. Cuando la función acaba, imagen es basura, Python la recoge, el destructor se ejecuta, y en Tcl desaparece la imagen. El widget conserva un nombre que ya no apunta a nada, y Tk dibuja lo que puede dibujar: nada.
Para ver qué hay realmente vivo, el intérprete tiene la lista:
>>> len(ventana.tk.call("image", "names"))
22Es el mejor sitio para mirar cuando una imagen no aparece y no entiendes por qué.
Qué formatos entiende Tk#
Aquí depende de la versión, y conviene comprobarlo en vez de suponerlo:
>>> ventana.tk.eval("info patchlevel")
'9.0.4'Con Tk 8.6, que es lo que viene con muchas instalaciones de Python, tk.PhotoImage lee GIF, PNG, PPM/PGM. Con Tk 9 se añade una que sorprende:
>>> tk.PhotoImage(file="dibujo.svg").width()
80
>>> tk.PhotoImage(file="dibujo.svg", format="svg -scale 2").width()
160SVG, y con escalado, sin instalar nada. Para iconos de barra de herramientas eso es una noticia excelente, porque un icono en SVG se ve nítido a cualquier tamaño y en cualquier pantalla, que es justo el problema de los iconos en PNG.
Lo que no está en ninguna versión es el JPEG:
>>> tk.PhotoImage(file="foto.jpg")
TclError: couldn't recognize data in image file "foto.jpg"Y ese es el motivo principal de instalar Pillow: las fotos del mundo real son JPEG.
Los dos errores al cargar son distintos y conviene distinguirlos, porque uno es culpa del usuario y el otro casi siempre del programa:
>>> tk.PhotoImage(file="roto.png")
TclError: couldn't recognize data in image file "roto.png"
>>> tk.PhotoImage(file="nada.png")
TclError: couldn't open "nada.png": no such file or directoryLos dos son TclError, así que hay que mirar el texto o, mejor, comprobar antes con pathlib que el fichero existe y dejar el except para el contenido.
Escribir imágenes#
write() guarda, y aquí hay un detalle que muerde:
>>> imagen.write("a.png", format="png") # ok
>>> imagen.write("a.ppm", format="ppm") # ok
>>> imagen.write("a.gif", format="gif")
TclError: too many colors
>>> imagen.write("a.jpg", format="jpeg")
TclError: image file format "jpeg" is unknownEl GIF solo admite 256 colores, así que cualquier degradado o foto no cabe. Y lo importante:
>>> pathlib.Path("a.gif").exists()
True
>>> pathlib.Path("a.gif").stat().st_size
6La escritura fallida deja el fichero creado y roto. Seis bytes de cabecera y nada más. Si tu programa guarda imágenes, un except TclError que solo avise no basta: hay que borrar el fichero a medias, o usar el os.replace sobre un temporal de la lección 43, que aquí sirve igual.
Escalar con lo que trae Tk#
Hay dos métodos y los dos solo admiten enteros:
>>> base.width(), base.height()
(64, 48)
>>> base.subsample(2).width()
32
>>> base.zoom(3).width()
192subsample(n) se queda con uno de cada n píxeles y zoom(n) repite cada píxel n veces. Rápidos, sin dependencias, y con tres problemas.
Uno: redondean hacia arriba. subsample(3) sobre 64 da 22, no 21, porque es el techo de la división.
Dos: los valores raros no avisan.
>>> base.subsample(1.5)
TclError: expected integer but got "1.5"
>>> base.subsample(0).width()
0
>>> base.subsample(-1).width()
641.5 falla con un error claro, que está bien. Pero 0 devuelve una imagen de 0×0 sin protestar y -1 devuelve la original tal cual. Si el factor sale de un cálculo, un cero accidental no da ningún error: da una imagen invisible, que es otra vez el fallo silencioso de esta lección.
Tres, y es el que decide si necesitas Pillow: no se puede llegar a un tamaño concreto. Desde 64×48, con enteros, se llega a 32×24, a 21×16, a 128×96… pero no a 100×75. Y «que la miniatura mida 100 píxeles de ancho» es exactamente lo que se pide en la vida real.
Hay una forma de acercarse, combinando los dos:
>>> escalado = tk.PhotoImage()
>>> escalado.copy_replace(base, zoom=2, subsample=3)
>>> escalado.width(), escalado.height()
(44, 32)Y el resultado enseña cómo funciona por dentro: subsample se aplica antes que zoom, así que de 64 sale techo(64/3) = 22 y luego 22×2 = 44, no los 42 que daría la fracción exacta. Los tamaños alcanzables son pocos y la calidad del reescalado es mala (es tirar píxeles, sin promediar). Sirve para un icono, no para una foto.
copy_replace es además la forma moderna de recortar:
>>> destino = tk.PhotoImage(width=32, height=24)
>>> destino.copy_replace(base, from_coords=(0, 0, 32, 24))Y una advertencia sobre otro método que parece lo que no es:
>>> copia.blank()
>>> copia.width(), copia.height()
(64, 48)blank() no borra la imagen: la deja del mismo tamaño y completamente transparente. Para quitar una imagen de un widget se pone image="".
Dónde se puede poner una imagen#
En más sitios de los que uno espera, y en todos con la misma regla de la referencia:
>>> ttk.Label(ventana, image=base) # y Button, Checkbutton, Radiobutton
>>> lienzo.create_image(10, 10, image=base, anchor="nw")
>>> texto.image_create("1.0", image=base)
>>> menu.add_command(label="Con icono", image=base, compound="left")
>>> arbol.insert("", "end", text="fila", image=base)
>>> ventana.iconphoto(True, base) # el icono de la ventanaEl compound es el que decide cómo conviven imagen y texto, y es el que hace las barras de herramientas de la lección 32:
>>> for valor in ("top", "bottom", "left", "right", "center", "none", "image", "text"):
... etiqueta.configure(compound=valor) # los ocho valen
>>> etiqueta.configure(compound="mal")
TclError: bad compound "mal": must be none, text, image, center, top, bottomcompound="image" muestra solo la imagen y compound="text" solo el texto, lo cual es cómodo: se pueden configurar las dos cosas una vez y cambiar de vista con una sola opción, que es cómo se hace un botón de «mostrar iconos y texto» en las preferencias.
Y el iconphoto de la ventana merece un recordatorio: también hay que mantener viva esa imagen. Un icono de ventana que se pone en el __init__ con una variable local desaparece por el mismo motivo que todo lo demás.
Pillow, para lo que Tk no llega#
Pillow es la biblioteca de imágenes de Python y no es de la biblioteca estándar, así que hay que instalarla:
python3 -m pip install pillowSe importa como PIL, por razones históricas (era la Python Imaging Library), y lo que hace falta son dos cosas: Image para trabajar con la imagen y ImageTk para convertirla en algo que Tk pueda dibujar.
from PIL import Image, ImageTk
original = Image.open("foto.jpg") # Tk no sabe leer esto
original.thumbnail((200, 200)) # cualquier tamaño, no solo enteros
lista = ImageTk.PhotoImage(original)
etiqueta.configure(image=lista)
etiqueta.imagen = lista # la referencia, otra vezLo que aporta, medido:
Formatos. 42 registrados, entre ellos JPEG, WEBP, TIFF, BMP e ICO.
Escalado a cualquier tamaño, con resize((ancho, alto)), y sobre todo con thumbnail, que es el que se quiere casi siempre porque respeta la proporción:
>>> img.size
(120, 90)
>>> copia = img.copy(); copia.thumbnail((100, 100)); copia.size
(100, 75)Dos cosas de thumbnail que sorprenden: modifica la imagen en el sitio y no devuelve nada (de ahí el copy() si quieres conservar la original), y nunca agranda:
>>> chica = Image.new("RGB", (30, 20))
>>> chica.thumbnail((200, 200)); chica.size
(30, 20)Eso último es una decisión sensata, no un fallo: agrandar una imagen pequeña se ve mal. Pero si esperabas que rellenara la caja, no lo hará.
Filtros de reescalado, que es lo que Tk no tiene:
img.resize((40, 30), Image.Resampling.LANCZOS)LANCZOS para reducir fotos (promedia, así que no aparecen esos dientes de sierra del subsample), y NEAREST cuando quieres el efecto contrario, que es conservar los píxeles cuadrados de un dibujo de píxel art.
Rotar, con un detalle útil:
>>> img.rotate(90, expand=True).size
(90, 120)
>>> img.transpose(Image.Transpose.ROTATE_90).size
(90, 120)Dan el mismo tamaño, pero rotate interpola (calcula píxeles nuevos) y transpose solo reordena. Para giros de 90, 180 y 270 grados usa transpose: es más rápido y no pierde nada.
Y un detalle de rendimiento que importa al abrir ficheros grandes: Image.open es perezoso. Lee la cabecera, así que size ya se sabe, pero no descomprime los píxeles hasta que hace falta. Por eso abrir un fichero enorme es instantáneo y el resize es lo que tarda, que es justo el caso de la lección 42: si vas a cargar una carpeta de fotos, el hilo va en el resize, no en el open.
Los errores de Pillow son excepciones de Python normales, lo cual es una mejora sobre los TclError:
>>> Image.open("falso.jpg")
UnidentifiedImageError: cannot identify image file 'falso.jpg'
>>> Image.open("nada.jpg")
FileNotFoundError: [Errno 2] No such file or directory: 'nada.jpg'UnidentifiedImageError hereda de OSError, así que un except OSError cubre las dos.
Si Pillow no está#
Este módulo no ha instalado nada de fuera hasta ahora, y una imagen no debería ser la razón de que un programa no arranque. Así que el ejemplo hace lo que conviene hacer siempre: importa Pillow si está, y si no, sigue con lo que trae Tk, avisando de lo que no podrá hacer.
try:
from PIL import Image, ImageTk
HAY_PILLOW = True
except ImportError:
HAY_PILLOW = FalseEse patrón es el estándar para una dependencia opcional, y tiene una ventaja que se nota al empaquetar (lección 45): el programa funciona en la máquina de alguien que no ha instalado nada.
El ejemplo#
Un visor de imágenes con miniaturas que usa Pillow si está y subsample si no, con las referencias bien guardadas y los dos errores de carga distinguidos.
"""Miniaturas con Pillow si está, y con lo de Tk si no."""
import math
import pathlib
import tkinter as tk
from tkinter import filedialog, ttk
try:
from PIL import Image, ImageTk, UnidentifiedImageError
HAY_PILLOW = True
except ImportError:
HAY_PILLOW = False
MARGEN = 12
AIRE = 6
GRIS = "#8E8E93"
ROSA = "#C2185B"
LADO_MINIATURA = 96
COLUMNAS = 4
# Lo que lee tk.PhotoImage sin ayuda. El SVG solo en Tk 9, y el JPEG nunca.
DE_TK = {".png", ".gif", ".ppm", ".pgm", ".svg"}
DE_PILLOW = {".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff", ".ico"}
class Miniatura(ttk.Frame):
"""Una imagen escalada con su nombre debajo.
Guarda la imagen como atributo: sin esa referencia la imagen se recoge y
queda un hueco, sin ningún error.
"""
def __init__(self, padre, ruta, al_elegir, **opciones):
super().__init__(padre, **opciones)
self.ruta = ruta
self.imagen = None # la referencia que lo mantiene vivo
self.problema = None
self.boton = ttk.Button(self, text=ruta.name, compound="top",
width=14, command=lambda: al_elegir(ruta))
self.boton.pack()
self.cargar()
def cargar(self):
try:
self.imagen = miniatura(self.ruta, LADO_MINIATURA)
except (OSError, tk.TclError) as error:
self.problema = error
self.boton.configure(text=f"{self.ruta.name}\n(no se puede leer)",
state="disabled")
return
# Solo aquí se le da la imagen al widget, y la referencia ya está puesta.
self.boton.configure(image=self.imagen)
def miniatura(ruta, lado):
"""Devuelve una imagen escalada lista para un widget.
Con Pillow cabe exactamente en el cuadrado; sin Pillow solo se puede
dividir por enteros, así que la miniatura sale más pequeña de la cuenta.
"""
if HAY_PILLOW:
original = Image.open(ruta)
original.thumbnail((lado, lado), Image.Resampling.LANCZOS)
# Hay que devolver la ImageTk, no la Image: la Image no le consta a Tk.
return ImageTk.PhotoImage(original)
imagen = tk.PhotoImage(file=str(ruta))
factor = max(1, math.ceil(max(imagen.width(), imagen.height()) / lado))
return imagen.subsample(factor) if factor > 1 else imagen
class Aplicacion(tk.Tk):
def __init__(self):
super().__init__()
self.title("Visor de imágenes")
self.minsize(560, 420)
self.grande = None # la imagen del panel derecho
self.miniaturas = [] # y las de la rejilla
self.estado = tk.StringVar()
cuerpo = ttk.Frame(self, padding=MARGEN)
cuerpo.pack(fill="both", expand=True)
barra = ttk.Frame(cuerpo)
barra.pack(fill="x")
ttk.Button(barra, text="Abrir carpeta…",
command=self.elegir_carpeta).pack(side="left")
ttk.Label(barra, text=self.que_se_puede(), foreground=GRIS).pack(
side="left", padx=AIRE)
paneles = ttk.Panedwindow(cuerpo, orient="horizontal")
paneles.pack(fill="both", expand=True, pady=(MARGEN, AIRE))
izquierda = ttk.Labelframe(paneles, text="Miniaturas", padding=AIRE)
self.rejilla = ttk.Frame(izquierda)
self.rejilla.pack(fill="both", expand=True)
derecha = ttk.Labelframe(paneles, text="Vista", padding=AIRE)
self.vista = ttk.Label(derecha, anchor="center")
self.vista.pack(fill="both", expand=True)
paneles.add(izquierda, weight=0)
paneles.add(derecha, weight=1)
self.pie = ttk.Label(cuerpo, textvariable=self.estado)
self.pie.pack(anchor="w")
self.centrar()
def que_se_puede(self):
version = self.tk.eval("info patchlevel")
if HAY_PILLOW:
return f"Tk {version} con Pillow: cualquier formato y cualquier tamaño"
return (f"Tk {version} sin Pillow: {' '.join(sorted(DE_TK))}"
" y escalado por enteros")
def extensiones_que_valen(self):
return DE_TK | (DE_PILLOW if HAY_PILLOW else set())
def elegir_carpeta(self):
carpeta = filedialog.askdirectory(parent=self, title="Carpeta de imágenes")
if not carpeta: # cancelar devuelve cadena vacía
return
self.mostrar_carpeta(pathlib.Path(carpeta))
def mostrar_carpeta(self, carpeta):
for pieza in self.miniaturas:
pieza.destroy() # y con ellas se van sus imágenes
self.miniaturas.clear()
validas = self.extensiones_que_valen()
rutas = sorted(ruta for ruta in carpeta.iterdir()
if ruta.is_file() and ruta.suffix.lower() in validas)
if not rutas:
self.estado.set(f"No hay imágenes legibles en {carpeta.name}")
self.pie.configure(foreground=GRIS)
return
for numero, ruta in enumerate(rutas[:24]):
pieza = Miniatura(self.rejilla, ruta, self.mostrar_grande,
padding=AIRE // 2)
pieza.grid(row=numero // COLUMNAS, column=numero % COLUMNAS)
self.miniaturas.append(pieza)
fallos = [p.ruta.name for p in self.miniaturas if p.problema]
if fallos:
self.estado.set(f"{len(rutas)} imágenes, {len(fallos)} ilegibles: "
f"{', '.join(fallos[:3])}")
self.pie.configure(foreground=ROSA)
else:
self.estado.set(f"{len(rutas)} imágenes en {carpeta.name}")
self.pie.configure(foreground=GRIS)
def mostrar_grande(self, ruta):
"""El panel derecho, a un tamaño que quepa."""
lado = max(200, min(self.vista.winfo_width(), self.vista.winfo_height()))
try:
self.grande = miniatura(ruta, lado)
except (OSError, tk.TclError) as error:
self.estado.set(f"No se ha podido abrir {ruta.name}: {error}")
self.pie.configure(foreground=ROSA)
return
self.vista.configure(image=self.grande)
self.estado.set(f"{ruta.name} — {self.grande.width()}×{self.grande.height()}"
f" px mostrados")
self.pie.configure(foreground=GRIS)
def centrar(self):
self.update_idletasks()
x = (self.winfo_screenwidth() - self.winfo_width()) // 2
y = (self.winfo_screenheight() - self.winfo_height()) // 2
self.geometry(f"+{x}+{y}")
if __name__ == "__main__":
Aplicacion().mainloop()