7. Carga y manipulación de imágenes en Python

7.1 Introducción

Hasta ahora vimos qué es la visión por computadora, qué tipos de problemas existen y cómo se representan digitalmente las imágenes. A partir de este tema empezamos a trabajar con una pregunta mucho más práctica: ¿cómo cargamos y manipulamos imágenes en Python?

En la práctica, casi todo flujo de visión artificial empieza igual: leer una imagen desde disco o desde una cámara, inspeccionar sus dimensiones, convertirla a una estructura adecuada, modificarla y luego visualizarla o guardarla.

Para hacer esto en Python se utilizan principalmente bibliotecas como OpenCV, Pillow y NumPy. En este tema aprenderemos los conceptos y operaciones básicas que forman la base del trabajo posterior.

7.2 Bibliotecas principales

Aunque existen varias herramientas, tres bibliotecas aparecen constantemente en proyectos de visión por computadora en Python:

  • OpenCV: muy completa para procesamiento de imágenes, video y operaciones clásicas de visión.
  • Pillow: práctica para abrir, convertir, manipular y guardar imágenes de forma simple.
  • NumPy: esencial porque las imágenes suelen manipularse como arreglos numéricos.

En muchos proyectos se combinan las tres. OpenCV aporta velocidad y funciones específicas de visión. Pillow facilita ciertas tareas de manejo de archivos e imágenes. NumPy actúa como base numérica para operar sobre los datos.

7.3 Instalación habitual

Una instalación común en Python puede hacerse con pip. Por ejemplo:

pip install opencv-python pillow numpy matplotlib

También es frecuente usar opencv-python-headless en entornos sin interfaz gráfica, como servidores o notebooks remotos. Más adelante veremos situaciones concretas donde esto puede ser relevante.

7.4 Leer una imagen con OpenCV

OpenCV permite cargar una imagen desde disco con una sola función. Un ejemplo básico es:

import cv2

imagen = cv2.imread("foto1.jpg")

if imagen is None:
    raise ValueError("No se pudo cargar la imagen")

print(imagen.shape)

Si la carga fue exitosa, imagen contendrá un arreglo numérico. Si hubo un problema con la ruta, el archivo o el formato, el valor será None.

Por eso es buena práctica verificar siempre el resultado:

import cv2

imagen = cv2.imread("foto1.jpg")

if imagen is None:
    print("No se pudo cargar la imagen")
else:
    print("Imagen cargada correctamente")

7.5 Leer una imagen con Pillow

Con Pillow, la carga de una imagen suele hacerse así:

from PIL import Image

imagen = Image.open("foto1.jpg")

print(imagen.size)

En este caso, el objeto devuelto no es inmediatamente un arreglo de NumPy, sino una estructura propia de Pillow. Si queremos operar numéricamente sobre ella, normalmente la convertimos:

from PIL import Image
import numpy as np

imagen = Image.open("foto1.jpg")
imagen_np = np.array(imagen)

print(imagen_np.shape)

Esta diferencia es importante: OpenCV trabaja directamente con arreglos, mientras que Pillow trabaja primero con objetos de imagen.

7.6 Diferencia importante entre OpenCV y Pillow

Ambas bibliotecas pueden cargar imágenes, pero hay una diferencia que suele causar confusión:

  • OpenCV normalmente usa el orden de canales BGR.
  • Pillow normalmente usa el orden RGB.

Esto significa que si cargamos una imagen con OpenCV y luego la mostramos con otra biblioteca que espera RGB, es posible que los colores se vean cambiados. Por eso, a menudo hace falta convertir:

import cv2

imagen = cv2.imread("foto1.jpg")

if imagen is None:
    raise ValueError("No se pudo cargar la imagen")

imagen_rgb = cv2.cvtColor(imagen, cv2.COLOR_BGR2RGB)

Esta diferencia existe por razones históricas: OpenCV nació en un contexto más orientado a visión por computadora y procesamiento de bajo nivel, mientras que Pillow siguió una convención más natural para gráficos e imágenes. Por eso OpenCV heredó BGR y Pillow adoptó RGB. No se unificó después porque cambiar OpenCV rompería muchísimo código, ejemplos y modelos ya existentes. En la práctica, lo correcto es recordar qué convención usa cada biblioteca y convertir cuando haga falta.

7.7 Visualizar imágenes

Ver una imagen cargada correctamente es una parte importante del flujo de trabajo, porque permite comprobar si los datos están en el formato esperado.

Con OpenCV se puede usar:

import cv2

imagen = cv2.imread("foto1.jpg")

if imagen is None:
    raise ValueError("No se pudo cargar la imagen")

cv2.imshow("Ventana", imagen)
cv2.waitKey(0)
cv2.destroyAllWindows()

Sin embargo, en notebooks o entornos interactivos muchas veces se prefiere usar Matplotlib:

import cv2
import matplotlib.pyplot as plt

imagen = cv2.imread("foto1.jpg")

if imagen is None:
    raise ValueError("No se pudo cargar la imagen")

imagen_rgb = cv2.cvtColor(imagen, cv2.COLOR_BGR2RGB)

plt.imshow(imagen_rgb)
plt.axis("off")
plt.show()

Si usamos OpenCV con Matplotlib, recordar la conversión BGR a RGB es esencial para no mostrar colores incorrectos. (Si comentamos la línea de conversión y ejecutamos otra vez el programa, Matplotlib interpretará la imagen como si estuviera en RGB cuando en realidad OpenCV la dejó en BGR. El resultado será una imagen con colores cambiados: las zonas rojas tenderán a verse azules y las zonas azules tenderán a verse rojas).

7.8 Inspeccionar forma y tipo de dato

Una de las primeras cosas que conviene hacer al cargar una imagen es revisar su forma y su tipo de dato:

import cv2

imagen = cv2.imread("foto1.jpg")

if imagen is None:
    raise ValueError("No se pudo cargar la imagen")

print(imagen.shape)
print(imagen.dtype)

Esto puede devolver algo como:

  • (480, 640, 3) para la forma.
  • uint8 para el tipo de dato.

La forma indica alto, ancho y cantidad de canales. El tipo de dato indica cómo están almacenados los valores numéricos.

7.9 Acceder a un píxel

Como las imágenes son arreglos, podemos acceder a píxeles individuales mediante índices. En OpenCV o NumPy, por ejemplo:

import cv2

imagen = cv2.imread("foto1.jpg")

if imagen is None:
    raise ValueError("No se pudo cargar la imagen")

pixel = imagen[100, 200]
print(pixel)

Si la imagen es en color, el resultado será un vector con tres componentes. Si es en grises, será un único valor.

Esto corresponde al píxel ubicado en la fila 100 y la columna 200.

7.10 Modificar un píxel

También podemos alterar directamente los valores de la imagen. Por ejemplo:

import cv2

imagen = cv2.imread("foto1.jpg")

if imagen is None:
    raise ValueError("No se pudo cargar la imagen")

imagen[100, 200] = [0, 0, 255]
cv2.imwrite("pixel_modificado.jpg", imagen)

Si la imagen está en BGR, ese píxel pasará a ser rojo puro en la convención de OpenCV. Este tipo de acceso directo es útil para experimentos, aunque en imágenes grandes conviene evitar modificaciones píxel a píxel con bucles lentos y preferir operaciones vectorizadas.

7.11 Extraer una región de interés

Una tarea muy frecuente es recortar una parte de la imagen. Esto se conoce como obtener una región de interés o ROI.

import cv2

imagen = cv2.imread("foto1.jpg")

if imagen is None:
    raise ValueError("No se pudo cargar la imagen")

recorte = imagen[50:200, 100:300]
cv2.imwrite("recorte.jpg", recorte)

Este ejemplo extrae las filas desde 50 hasta 199 y las columnas desde 100 hasta 299. El resultado es una nueva vista o subimagen que puede analizarse por separado.

Los recortes son fundamentales en tareas como detección, segmentación localizada, análisis facial o procesamiento de documentos.

7.12 Copia versus referencia

Cuando recortamos con slicing en NumPy, muchas veces no obtenemos una copia independiente, sino una vista sobre los mismos datos. Esto significa que modificar el recorte puede modificar la imagen original.

Si necesitamos una copia independiente, podemos usar:

import cv2

imagen = cv2.imread("foto1.jpg")

if imagen is None:
    raise ValueError("No se pudo cargar la imagen")

recorte = imagen[50:200, 100:300].copy()
cv2.imwrite("recorte_copia.jpg", recorte)

Comprender esta diferencia evita errores difíciles de detectar durante el procesamiento.

7.13 Redimensionar imágenes

Muchas tareas requieren cambiar el tamaño de una imagen. Esto puede ser necesario para:

  • Adaptar una imagen a un modelo.
  • Reducir costo computacional.
  • Normalizar un dataset.
  • Visualizar resultados.

Con OpenCV, una forma común es:

import cv2

imagen = cv2.imread("foto1.jpg")

if imagen is None:
    raise ValueError("No se pudo cargar la imagen")

redimensionada = cv2.resize(imagen, (300, 200))
cv2.imwrite("redimensionada.jpg", redimensionada)

Aquí el nuevo tamaño se da como (ancho, alto), algo que conviene recordar porque no sigue la misma convención que el acceso por filas y columnas.

7.14 Mantener proporciones

Cuando redimensionamos, muchas veces conviene conservar la relación de aspecto para evitar deformaciones. Por ejemplo:

import cv2

imagen = cv2.imread("foto1.jpg")

if imagen is None:
    raise ValueError("No se pudo cargar la imagen")

alto, ancho = imagen.shape[:2]
nuevo_ancho = 400
nuevo_alto = int(alto * nuevo_ancho / ancho)

redimensionada = cv2.resize(imagen, (nuevo_ancho, nuevo_alto))
cv2.imwrite("redimensionada_proporcional.jpg", redimensionada)

Si no respetamos la proporción, los objetos pueden quedar estirados o comprimidos, lo que a veces afecta el análisis visual y el rendimiento de un modelo.

7.15 Cambiar entre color y escala de grises

Es muy común convertir imágenes a grises para simplificar operaciones posteriores. Con OpenCV:

import cv2

imagen = cv2.imread("foto1.jpg")

if imagen is None:
    raise ValueError("No se pudo cargar la imagen")

gris = cv2.cvtColor(imagen, cv2.COLOR_BGR2GRAY)
cv2.imwrite("gris.jpg", gris)

Esto transforma una imagen de tres canales en una imagen de un solo canal. Muchas técnicas clásicas de procesamiento trabajan mejor o más rápido sobre este tipo de representación.

7.16 Separar canales

Otra operación frecuente es separar los canales de una imagen para analizarlos de forma individual:

import cv2

imagen = cv2.imread("foto1.jpg")

if imagen is None:
    raise ValueError("No se pudo cargar la imagen")

b, g, r = cv2.split(imagen)
print(b.shape, g.shape, r.shape)

Esto permite inspeccionar qué componente resalta mejor una región o aplicar procesamiento específico sobre cada canal.

También es posible volver a unirlos:

import cv2

imagen = cv2.imread("foto1.jpg")

if imagen is None:
    raise ValueError("No se pudo cargar la imagen")

b, g, r = cv2.split(imagen)
imagen_unida = cv2.merge([b, g, r])
cv2.imwrite("imagen_unida.jpg", imagen_unida)

7.17 Rotar y voltear imágenes

Algunas manipulaciones básicas cambian la orientación de la imagen. Por ejemplo, podemos voltear horizontal o verticalmente:

import cv2

imagen = cv2.imread("foto1.jpg")

if imagen is None:
    raise ValueError("No se pudo cargar la imagen")

horizontal = cv2.flip(imagen, 1)
vertical = cv2.flip(imagen, 0)

Estas operaciones son útiles tanto en preprocesamiento como en data augmentation.

7.18 Guardar una imagen

Después de procesar una imagen, es habitual guardarla en disco. Con OpenCV se hace así:

import cv2

imagen = cv2.imread("foto1.jpg")

if imagen is None:
    raise ValueError("No se pudo cargar la imagen")

cv2.imwrite("salida.jpg", imagen)

El formato de salida suele inferirse a partir de la extensión del archivo. Esto permite exportar resultados intermedios, visualizaciones o imágenes transformadas.

7.19 Conversión entre Pillow y NumPy

En muchos proyectos se mezclan Pillow y NumPy. Una conversión común es:

from PIL import Image
import numpy as np

imagen_pil = Image.open("foto1.jpg")
imagen_np = np.array(imagen_pil)

print(imagen_np.shape)

Y si queremos volver desde NumPy a Pillow:

from PIL import Image
import numpy as np

imagen_np = np.zeros((200, 300, 3), dtype=np.uint8)
imagen_np[:, :] = [255, 0, 0]

imagen_pil = Image.fromarray(imagen_np)
imagen_pil.save("imagen_desde_numpy.png")

Esto es útil cuando una biblioteca ofrece una función conveniente pero el resto del pipeline trabaja con otro formato interno.

7.20 Operaciones vectorizadas

Cuando trabajamos con imágenes en Python conviene aprovechar las operaciones vectorizadas de NumPy en lugar de recorrer píxeles con bucles explícitos. Por ejemplo, si queremos aclarar una imagen:

import cv2
import numpy as np

imagen = cv2.imread("foto1.jpg")

if imagen is None:
    raise ValueError("No se pudo cargar la imagen")

aclarada = np.clip(imagen + 30, 0, 255).astype(np.uint8)
cv2.imwrite("aclarada.jpg", aclarada)

Este enfoque es mucho más eficiente que modificar cada píxel uno por uno en un for.

7.21 Cuidado con overflow y tipos de dato

Una fuente común de errores aparece al operar con imágenes uint8. Como estos valores están limitados entre 0 y 255, ciertas operaciones pueden producir desbordamientos si no se controlan bien.

Por eso es frecuente usar:

  • Conversión temporal a flotante.
  • np.clip para limitar valores.
  • Reconversión a uint8 al final.

Este detalle parece menor, pero es clave para evitar resultados numéricos inesperados.

7.22 Trabajar con rutas de archivo

En programas reales conviene manejar correctamente las rutas de las imágenes. Una mala ruta es una de las causas más frecuentes de error al cargar archivos.

En Python suele ser recomendable usar pathlib:

from pathlib import Path
import cv2

ruta = Path("imagenes") / "foto1.jpg"
imagen = cv2.imread(str(ruta))

if imagen is None:
    raise ValueError(f"No se pudo cargar la imagen: {ruta}")

Esto ayuda a escribir código más claro y portable.

7.23 Lectura en lote

Cuando trabajamos con datasets, no suele alcanzarnos con cargar una sola imagen. Es común recorrer directorios completos:

from pathlib import Path
import cv2

for ruta in Path("imagenes").glob("*.jpg"):
    imagen = cv2.imread(str(ruta))
    if imagen is None:
        continue
    print(ruta.name, imagen.shape)

Este tipo de lectura es la base de pipelines de entrenamiento, validación y análisis exploratorio.

7.24 Flujo básico de trabajo con imágenes

Un flujo práctico y simple de manipulación de imágenes suele seguir estos pasos:

  1. Cargar la imagen.
  2. Verificar que se leyó correctamente.
  3. Inspeccionar forma, canales y tipo de dato.
  4. Convertir de espacio de color si hace falta.
  5. Aplicar recortes, redimensionado o transformaciones.
  6. Visualizar o guardar el resultado.

Este patrón se repite muchísimas veces en proyectos reales.

7.25 Ejemplo integrado

Un ejemplo sencillo combinando varias operaciones podría ser:

import cv2
import matplotlib.pyplot as plt

imagen = cv2.imread("foto1.jpg")

if imagen is None:
    raise ValueError("No se pudo cargar la imagen")

gris = cv2.cvtColor(imagen, cv2.COLOR_BGR2GRAY)
redimensionada = cv2.resize(gris, (300, 200))

plt.imshow(redimensionada, cmap="gray")
plt.axis("off")
plt.show()

En este código:

  • Se carga la imagen.
  • Se verifica que exista.
  • Se convierte a grises.
  • Se redimensiona.
  • Se muestra en pantalla.

7.26 Errores comunes al empezar

Al comenzar a manipular imágenes en Python, es frecuente cometer algunos errores típicos:

  • No verificar si la carga devolvió None.
  • Confundir BGR con RGB.
  • Olvidar revisar shape y dtype.
  • Modificar una vista pensando que es una copia.
  • Usar un tamaño incorrecto al redimensionar.
  • No controlar desbordamientos al operar con uint8.

Evitar estos problemas desde el principio mejora mucho la estabilidad del código y la interpretación de resultados.

7.27 Qué debes recordar de este tema

  • En Python, OpenCV, Pillow y NumPy forman la base del trabajo con imágenes.
  • OpenCV carga normalmente en BGR, mientras que Pillow suele trabajar en RGB.
  • Siempre conviene verificar la carga, inspeccionar la forma y revisar el tipo de dato.
  • Las imágenes pueden recortarse, redimensionarse, convertirse y guardarse fácilmente.
  • NumPy permite manipular imágenes como arreglos y aplicar operaciones vectorizadas eficientes.
  • Trabajar bien con rutas, copias y conversiones evita muchos errores comunes.

7.28 Conclusión

La carga y manipulación de imágenes en Python es la puerta de entrada a la práctica real de la visión por computadora. Antes de aplicar filtros avanzados, entrenar redes neuronales o construir detectores, primero hay que dominar operaciones básicas como leer, inspeccionar, convertir, recortar y guardar imágenes.

Estas tareas parecen simples, pero forman la infraestructura cotidiana de casi cualquier proyecto visual. Cuanto más sólidas sean estas bases, más fácil será avanzar hacia procesamiento clásico y Deep Learning.

En el próximo tema veremos las librerías fundamentales para visión por computadora, ordenando mejor el ecosistema de herramientas que usaremos durante el curso.