12. Transformaciones geométricas de imágenes

12.1 Introducción

Hasta ahora trabajamos principalmente con operaciones que modifican los valores de los píxeles. En este tema cambiaremos de enfoque: estudiaremos transformaciones que modifican la posición espacial de esos píxeles dentro de la imagen.

Estas transformaciones se llaman geométricas porque alteran la geometría visual de la imagen: pueden desplazarla, rotarla, escalarla, reflejarla o deformarla según ciertas reglas matemáticas.

Son muy importantes en visión por computadora porque aparecen en preprocesamiento, corrección de perspectiva, alineación de documentos, data augmentation, seguimiento de objetos y muchos otros contextos.

12.2 ¿Qué cambia en una transformación geométrica?

En una transformación geométrica no nos interesa tanto cambiar el valor de cada píxel como decidir dónde debe quedar cada punto de la imagen en la nueva representación.

Esto significa que la pregunta principal pasa a ser:

¿cómo se reasignan las coordenadas de los píxeles?

Por ejemplo:

  • En una traslación, los píxeles se desplazan.
  • En una rotación, giran alrededor de un punto.
  • En un escalado, se expanden o comprimen.
  • En una proyección de perspectiva, cambian según una deformación más compleja.
Las transformaciones geométricas modifican la ubicación de la información visual. No necesariamente cambian qué color tiene cada píxel, sino dónde aparece.

12.3 ¿Por qué son importantes?

Estas transformaciones aparecen constantemente en problemas reales. Algunos ejemplos:

  • Corregir una foto de un documento tomada en ángulo.
  • Rotar una imagen para alinearla correctamente.
  • Redimensionar datos antes de alimentar una red neuronal.
  • Aumentar un dataset con rotaciones y flips.
  • Extraer vistas normalizadas de rostros, matrículas o etiquetas.

En otras palabras, muchas veces la imagen original no está en la forma ideal para ser analizada, y una transformación geométrica permite llevarla a una representación más conveniente.

12.4 Transformaciones rígidas y no rígidas

De forma general, podemos pensar que algunas transformaciones preservan mejor la forma original y otras introducen deformaciones más complejas.

  • Transformaciones simples o rígidas: traslación, rotación, reflexión.
  • Transformaciones con cambio de escala: amplían o reducen tamaños.
  • Transformaciones afines o proyectivas: permiten deformaciones más generales.

Esta distinción es útil porque no todos los problemas requieren el mismo nivel de complejidad geométrica.

12.5 Escalado o redimensionado

Una de las transformaciones geométricas más comunes es el escalado, es decir, cambiar el tamaño de la imagen. Ya vimos la operación desde un punto de vista práctico, pero aquí la pensamos como transformación espacial.

Con OpenCV:

redimensionada = cv2.resize(imagen, (400, 300))

Escalar una imagen implica recalcular qué valor debe tener cada posición nueva en función de la imagen original. Esto introduce un concepto importante: la interpolación, que veremos más adelante.

12.6 Mantener proporción

Cuando escalamos una imagen, a menudo queremos conservar la relación de aspecto. Si no lo hacemos, la imagen puede deformarse visualmente.

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

redimensionada = cv2.resize(imagen, (nuevo_ancho, nuevo_alto))

Este cuidado es importante tanto en visualización como en entrenamiento de modelos, donde una deformación artificial puede perjudicar el aprendizaje.

12.7 Traslación

La traslación consiste en desplazar la imagen horizontal y verticalmente. No cambia la forma ni la orientación de los objetos, solo su posición.

Matemáticamente, esto equivale a sumar un desplazamiento a las coordenadas de cada punto.

En OpenCV se puede implementar con una matriz de transformación y warpAffine:

import cv2
import numpy as np

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

M = np.float32([[1, 0, 50],
                [0, 1, 30]])

trasladada = cv2.warpAffine(imagen, M, (imagen.shape[1], imagen.shape[0]))

cv2.imshow("Imagen original", imagen)
cv2.imshow("Imagen trasladada", trasladada)
cv2.waitKey(0)
cv2.destroyAllWindows()

En este ejemplo, la imagen se desplaza 50 píxeles a la derecha y 30 hacia abajo.

12.8 Rotación

La rotación gira la imagen alrededor de un punto. Generalmente se usa el centro de la imagen, aunque no es obligatorio.

OpenCV ofrece una forma práctica de construir esta transformación:

import cv2

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

alto, ancho = imagen.shape[:2]
centro = (ancho // 2, alto // 2)

M = cv2.getRotationMatrix2D(centro, 45, 1.0)
rotada = cv2.warpAffine(imagen, M, (ancho, alto))

cv2.imshow("Imagen original", imagen)
cv2.imshow("Imagen rotada", rotada)
cv2.waitKey(0)
cv2.destroyAllWindows()

Aquí la imagen se rota 45 grados con escala 1.0, es decir, sin agrandarla ni reducirla.

12.9 Problemas comunes al rotar

Cuando rotamos una imagen pueden aparecer varios problemas prácticos:

  • Partes de la imagen pueden quedar fuera del encuadre.
  • Surgen zonas vacías en las esquinas.
  • El tamaño original puede no ser suficiente para contener la imagen rotada.

Por eso, en algunos casos se amplía el lienzo de salida o se calculan dimensiones nuevas para no perder contenido.

12.10 Flip o reflexión

El flip es una reflexión de la imagen. Puede ser horizontal, vertical o ambas. Es una operación muy útil tanto para inspección como para data augmentation.

import cv2

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

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

cv2.imshow("Imagen original", imagen)
cv2.imshow("Flip horizontal", horizontal)
cv2.imshow("Flip vertical", vertical)
cv2.imshow("Flip horizontal y vertical", ambos)
cv2.waitKey(0)
cv2.destroyAllWindows()

El flip horizontal es especialmente común cuando se generan variantes de entrenamiento para modelos de clasificación o detección.

12.11 Recorte como transformación geométrica

El recorte también puede considerarse una transformación geométrica, porque altera la región espacial visible de la imagen. Aunque técnicamente es más simple que una rotación o una proyección, su efecto geométrico es claro: conservamos solo una parte del plano original.

recorte = imagen[100:350, 150:450]

Es una operación fundamental en detección, seguimiento, OCR y preparación de regiones de interés.

12.12 Interpolación

Muchas transformaciones geométricas generan coordenadas nuevas que no coinciden exactamente con posiciones enteras de la imagen original. En esos casos hace falta estimar el valor del nuevo píxel. A eso se le llama interpolación.

OpenCV ofrece varios métodos, entre ellos:

  • INTER_NEAREST
  • INTER_LINEAR
  • INTER_CUBIC
  • INTER_AREA

La interpolación elegida influye en la calidad visual y en el costo computacional.

12.13 Cuándo usar cada interpolación

Como regla práctica:

  • Nearest neighbor: rápida, pero más tosca.
  • Linear: buen equilibrio entre calidad y velocidad.
  • Cubic: mejor calidad en muchos casos, más costosa.
  • Area: útil al reducir tamaño.

En una redimensión simple de imágenes para entrenamiento, una interpolación lineal suele ser una opción razonable. Para imágenes con texto o máscaras, a veces la elección debe hacerse con más cuidado.

12.14 Transformaciones afines

Una transformación afín es más general que una simple traslación o rotación. Permite combinar operaciones como:

  • Traslación.
  • Rotación.
  • Escalado.
  • Cizallamiento o deformación lineal.

Una característica importante es que preserva líneas rectas y paralelismo, aunque puede alterar ángulos y longitudes.

En OpenCV, las transformaciones afines se aplican también con cv2.warpAffine.

12.15 Ejemplo de transformación afín

Para definir una transformación afín se suelen especificar tres puntos en la imagen original y sus posiciones correspondientes en la salida:

import cv2
import numpy as np

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

pts1 = np.float32([[50, 50], [200, 50], [50, 200]])
pts2 = np.float32([[10, 100], [200, 50], [100, 250]])

M = cv2.getAffineTransform(pts1, pts2)
afin = cv2.warpAffine(imagen, M, (imagen.shape[1], imagen.shape[0]))

cv2.imshow("Imagen original", imagen)
cv2.imshow("Transformacion afin", afin)
cv2.waitKey(0)
cv2.destroyAllWindows()

Esta transformación puede inclinar o deformar la imagen dentro del modelo afín.

12.16 Transformación de perspectiva

La transformación de perspectiva es aún más general. Se utiliza cuando la imagen parece observada desde un ángulo oblicuo y queremos rectificarla como si la estuviéramos viendo de frente.

Es especialmente útil en:

  • Escaneo de documentos.
  • Lectura de matrículas.
  • Rectificación de pantallas o carteles.
  • Corrección de imágenes tomadas con perspectiva inclinada.

En este caso se necesitan cuatro puntos y se usa una matriz proyectiva.

12.17 Ejemplo de perspectiva

import cv2
import numpy as np

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

pts1 = np.float32([[56, 65], [368, 52], [28, 387], [389, 390]])
pts2 = np.float32([[0, 0], [300, 0], [0, 400], [300, 400]])

M = cv2.getPerspectiveTransform(pts1, pts2)
perspectiva = cv2.warpPerspective(imagen, M, (300, 400))

cv2.imshow("Imagen original", imagen)
cv2.imshow("Transformacion de perspectiva", perspectiva)
cv2.waitKey(0)
cv2.destroyAllWindows()

Este tipo de transformación puede “enderezar” visualmente una región inclinada y convertirla en una vista más regular.

12.18 warpAffine y warpPerspective

Dos funciones de OpenCV aparecen constantemente al trabajar con transformaciones geométricas:

  • cv2.warpAffine para transformaciones afines.
  • cv2.warpPerspective para transformaciones proyectivas.

Ambas toman una imagen, una matriz de transformación y un tamaño de salida, y producen una nueva imagen reubicando los píxeles según esa geometría.

12.19 Tamaño de salida y zonas vacías

En una transformación geométrica, no solo importa la matriz de cambio. También importa el tamaño de salida elegido. Si la imagen transformada no entra en el nuevo lienzo, se perderá parte del contenido. Si entra con espacio sobrante, aparecerán regiones vacías.

En OpenCV, esas zonas vacías suelen rellenarse con un color por defecto, normalmente negro, aunque esto puede ajustarse según la función utilizada.

12.20 Transformaciones y data augmentation

En Deep Learning, las transformaciones geométricas tienen otro papel muy importante: generar nuevas variantes de las imágenes de entrenamiento. Esto ayuda a mejorar la generalización del modelo.

Algunos ejemplos de aumentos geométricos:

  • Pequeñas rotaciones.
  • Flips horizontales.
  • Escalados moderados.
  • Recortes aleatorios.
  • Desplazamientos ligeros.

La idea es enseñar al modelo a reconocer objetos aunque cambien de posición, orientación o tamaño.

12.21 Precauciones en tareas supervisadas

Cuando una imagen tiene anotaciones asociadas, como cajas, máscaras o puntos clave, no alcanza con transformar solo la imagen. También hay que transformar esas anotaciones de forma consistente.

Por ejemplo:

  • Si rotamos la imagen, la caja del objeto también debe rotarse o recalcularse.
  • Si hacemos flip horizontal, las coordenadas de los puntos clave cambian.
  • Si recortamos, las máscaras deben recortarse igual.

Este detalle es esencial en datasets para detección, pose y segmentación.

12.22 Orden de las transformaciones

El orden en que aplicamos transformaciones geométricas importa. Rotar y luego trasladar no siempre produce el mismo resultado que trasladar y luego rotar. Esto ocurre porque las coordenadas se van redefiniendo en cada paso.

En problemas complejos, esta composición de transformaciones se expresa mediante matrices que se combinan entre sí.

12.23 Ejemplo integrado

Veamos ahora un ejemplo integrado y más completo con una interfaz gráfica para aplicar distintas transformaciones geométricas sobre una imagen:

Aplicación gráfica de transformaciones geométricas con OpenCV
import os
import cv2
import numpy as np
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
from PIL import Image, ImageTk


EXTENSIONES_VALIDAS = (".jpg", ".jpeg", ".png", ".bmp", ".tif", ".tiff", ".webp")


class AplicacionTransformacionesGeometricas:
    def __init__(self, root):
        self.root = root
        self.root.title("Transformaciones geométricas con OpenCV")
        self.root.geometry("1550x900")

        self.imagen_original = None
        self.imagen_actual = None
        self.nombre_archivo_actual = None

        self.crear_interfaz()
        self.cargar_lista_imagenes()

    # ---------------------------------------------------------
    # INTERFAZ
    # ---------------------------------------------------------
    def crear_interfaz(self):
        contenedor = ttk.Frame(self.root, padding=10)
        contenedor.pack(fill="both", expand=True)

        # ---------------- Panel izquierdo ----------------
        panel_izquierdo = ttk.Frame(contenedor)
        panel_izquierdo.pack(side="left", fill="y", padx=(0, 10))

        ttk.Label(panel_izquierdo, text="Imágenes del directorio actual").pack(anchor="w")

        self.lista_imagenes = tk.Listbox(panel_izquierdo, width=35, height=18)
        self.lista_imagenes.pack(fill="y", pady=5)
        self.lista_imagenes.bind("<<ListboxSelect>>", self.seleccionar_imagen)

        ttk.Button(panel_izquierdo, text="Actualizar lista", command=self.cargar_lista_imagenes).pack(fill="x", pady=2)
        ttk.Button(panel_izquierdo, text="Abrir otra imagen...", command=self.abrir_otra_imagen).pack(fill="x", pady=2)
        ttk.Button(panel_izquierdo, text="Restablecer imagen", command=self.restablecer_imagen).pack(fill="x", pady=2)
        ttk.Button(panel_izquierdo, text="Guardar resultado", command=self.guardar_resultado).pack(fill="x", pady=2)

        ttk.Separator(panel_izquierdo, orient="horizontal").pack(fill="x", pady=8)
        ttk.Label(panel_izquierdo, text="Parámetros").pack(anchor="w")

        ttk.Label(panel_izquierdo, text="Nuevo ancho").pack(anchor="w")
        self.entry_ancho = ttk.Entry(panel_izquierdo)
        self.entry_ancho.insert(0, "500")
        self.entry_ancho.pack(fill="x", pady=2)

        ttk.Label(panel_izquierdo, text="Nuevo alto").pack(anchor="w")
        self.entry_alto = ttk.Entry(panel_izquierdo)
        self.entry_alto.insert(0, "350")
        self.entry_alto.pack(fill="x", pady=2)

        ttk.Label(panel_izquierdo, text="Desplazamiento X").pack(anchor="w")
        self.entry_tx = ttk.Entry(panel_izquierdo)
        self.entry_tx.insert(0, "50")
        self.entry_tx.pack(fill="x", pady=2)

        ttk.Label(panel_izquierdo, text="Desplazamiento Y").pack(anchor="w")
        self.entry_ty = ttk.Entry(panel_izquierdo)
        self.entry_ty.insert(0, "30")
        self.entry_ty.pack(fill="x", pady=2)

        ttk.Label(panel_izquierdo, text="Ángulo de rotación").pack(anchor="w")
        self.scale_angulo = tk.Scale(panel_izquierdo, from_=-180, to=180,
                                     orient="horizontal", length=250)
        self.scale_angulo.set(30)
        self.scale_angulo.pack()

        ttk.Label(panel_izquierdo, text="Escala de rotación").pack(anchor="w")
        self.scale_escala_rot = tk.Scale(panel_izquierdo, from_=0.1, to=2.0,
                                         resolution=0.1, orient="horizontal", length=250)
        self.scale_escala_rot.set(1.0)
        self.scale_escala_rot.pack()

        ttk.Label(panel_izquierdo, text="Recorte (%) del ancho").pack(anchor="w")
        self.scale_crop_x = tk.Scale(panel_izquierdo, from_=10, to=100,
                                     orient="horizontal", length=250)
        self.scale_crop_x.set(50)
        self.scale_crop_x.pack()

        ttk.Label(panel_izquierdo, text="Recorte (%) del alto").pack(anchor="w")
        self.scale_crop_y = tk.Scale(panel_izquierdo, from_=10, to=100,
                                     orient="horizontal", length=250)
        self.scale_crop_y.set(50)
        self.scale_crop_y.pack()

        ttk.Label(panel_izquierdo, text="Interpolación").pack(anchor="w")
        self.combo_interpolacion = ttk.Combobox(
            panel_izquierdo,
            state="readonly",
            values=["INTER_NEAREST", "INTER_LINEAR", "INTER_CUBIC", "INTER_AREA"]
        )
        self.combo_interpolacion.current(1)
        self.combo_interpolacion.pack(fill="x", pady=2)

        ttk.Separator(panel_izquierdo, orient="horizontal").pack(fill="x", pady=8)

        texto_ayuda = (
            "Funciones incluidas:\n"
            "- Escalado y proporción\n"
            "- Traslación\n"
            "- Rotación\n"
            "- Flip\n"
            "- Recorte\n"
            "- Afín\n"
            "- Perspectiva\n"
            "- Ejemplo integrado"
        )
        ttk.Label(panel_izquierdo, text=texto_ayuda, foreground="blue", justify="left").pack(anchor="w", pady=5)

        # ---------------- Panel central ----------------
        panel_centro = ttk.Frame(contenedor)
        panel_centro.pack(side="left", fill="y", padx=(0, 10))

        ttk.Label(panel_centro, text="Herramientas geométricas").pack(anchor="w")

        self.frame_botones = ttk.Frame(panel_centro)
        self.frame_botones.pack(fill="y", pady=5)

        botones = [
            ("Redimensionar", self.redimensionar),
            ("Mantener proporción", self.redimensionar_proporcion),
            ("Trasladar", self.trasladar),
            ("Rotar", self.rotar),
            ("Rotar sin recortar", self.rotar_sin_recorte),
            ("Flip horizontal", self.flip_horizontal),
            ("Flip vertical", self.flip_vertical),
            ("Flip ambos", self.flip_ambos),
            ("Recorte central", self.recorte_central),
            ("Transformación afín", self.transformacion_afin),
            ("Transformación perspectiva", self.transformacion_perspectiva),
            ("Ejemplo integrado", self.ejemplo_integrado),
        ]

        fila = 0
        col = 0
        for texto, comando in botones:
            btn = ttk.Button(self.frame_botones, text=texto, command=comando, width=24)
            btn.grid(row=fila, column=col, padx=3, pady=3, sticky="ew")
            fila += 1
            if fila == 6:
                fila = 0
                col += 1

        # ---------------- Panel derecho ----------------
        panel_derecho = ttk.Frame(contenedor)
        panel_derecho.pack(side="left", fill="both", expand=True)

        ttk.Label(panel_derecho, text="Imagen original").pack(anchor="center", pady=(0, 5))
        self.label_original = ttk.Label(panel_derecho, relief="solid", anchor="center")
        self.label_original.pack(fill="both", expand=True, padx=5, pady=(0, 10))

        ttk.Label(panel_derecho, text="Imagen transformada").pack(anchor="center", pady=(0, 5))
        self.label_procesada = ttk.Label(panel_derecho, relief="solid", anchor="center")
        self.label_procesada.pack(fill="both", expand=True, padx=5, pady=(0, 5))

        self.label_info = ttk.Label(
            panel_derecho,
            text="Seleccione una imagen para comenzar.",
            foreground="blue"
        )
        self.label_info.pack(anchor="w", pady=5)

    # ---------------------------------------------------------
    # UTILIDADES
    # ---------------------------------------------------------
    def cargar_lista_imagenes(self):
        self.lista_imagenes.delete(0, tk.END)

        archivos = sorted([
            archivo for archivo in os.listdir(".")
            if os.path.isfile(archivo) and archivo.lower().endswith(EXTENSIONES_VALIDAS)
        ])

        for archivo in archivos:
            self.lista_imagenes.insert(tk.END, archivo)

    def seleccionar_imagen(self, event=None):
        seleccion = self.lista_imagenes.curselection()
        if not seleccion:
            return

        nombre = self.lista_imagenes.get(seleccion[0])
        imagen = cv2.imread(nombre)

        if imagen is None:
            messagebox.showerror("Error", f"No se pudo cargar la imagen:\n{nombre}")
            return

        self.nombre_archivo_actual = nombre
        self.imagen_original = imagen.copy()
        self.imagen_actual = imagen.copy()

        self.mostrar_imagen(self.imagen_original, self.label_original)
        self.mostrar_imagen(self.imagen_actual, self.label_procesada)
        self.actualizar_info()

    def abrir_otra_imagen(self):
        ruta = filedialog.askopenfilename(
            title="Seleccionar imagen",
            filetypes=[
                ("Imágenes", "*.jpg *.jpeg *.png *.bmp *.tif *.tiff *.webp"),
                ("Todos los archivos", "*.*")
            ]
        )

        if not ruta:
            return

        imagen = cv2.imread(ruta)
        if imagen is None:
            messagebox.showerror("Error", "No se pudo cargar la imagen seleccionada.")
            return

        self.nombre_archivo_actual = os.path.basename(ruta)
        self.imagen_original = imagen.copy()
        self.imagen_actual = imagen.copy()

        self.mostrar_imagen(self.imagen_original, self.label_original)
        self.mostrar_imagen(self.imagen_actual, self.label_procesada)
        self.actualizar_info()

    def mostrar_imagen(self, imagen_cv, label):
        if imagen_cv is None:
            return

        imagen_mostrar = self.convertir_para_tk(imagen_cv)

        max_ancho = 700
        max_alto = 350

        alto, ancho = imagen_mostrar.shape[:2]
        escala = min(max_ancho / ancho, max_alto / alto, 1.0)

        nuevo_ancho = int(ancho * escala)
        nuevo_alto = int(alto * escala)

        imagen_redim = cv2.resize(imagen_mostrar, (nuevo_ancho, nuevo_alto))

        imagen_pil = Image.fromarray(imagen_redim)
        imagen_tk = ImageTk.PhotoImage(imagen_pil)

        label.configure(image=imagen_tk)
        label.image = imagen_tk

    def convertir_para_tk(self, imagen_cv):
        if len(imagen_cv.shape) == 2:
            return imagen_cv
        return cv2.cvtColor(imagen_cv, cv2.COLOR_BGR2RGB)

    def actualizar_vista_procesada(self):
        if self.imagen_actual is not None:
            self.mostrar_imagen(self.imagen_actual, self.label_procesada)
            self.actualizar_info()

    def actualizar_info(self):
        if self.imagen_actual is None:
            self.label_info.config(text="No hay imagen cargada.")
            return

        forma = self.imagen_actual.shape
        texto = f"Archivo: {self.nombre_archivo_actual} | Tamaño: {forma[1]} x {forma[0]}"

        if len(forma) == 2:
            texto += " | 1 canal"
        else:
            texto += f" | {forma[2]} canales"

        self.label_info.config(text=texto)

    def verificar_imagen(self):
        if self.imagen_actual is None:
            messagebox.showwarning("Aviso", "Primero debe seleccionar una imagen.")
            return False
        return True

    def restablecer_imagen(self):
        if self.imagen_original is None:
            return
        self.imagen_actual = self.imagen_original.copy()
        self.actualizar_vista_procesada()

    def guardar_resultado(self):
        if not self.verificar_imagen():
            return

        ruta = filedialog.asksaveasfilename(
            title="Guardar imagen transformada",
            defaultextension=".png",
            filetypes=[
                ("PNG", "*.png"),
                ("JPG", "*.jpg"),
                ("BMP", "*.bmp"),
                ("Todos los archivos", "*.*")
            ]
        )

        if not ruta:
            return

        ok = cv2.imwrite(ruta, self.imagen_actual)
        if ok:
            messagebox.showinfo("Guardar", "Imagen guardada correctamente.")
        else:
            messagebox.showerror("Error", "No se pudo guardar la imagen.")

    def obtener_interpolacion(self):
        nombre = self.combo_interpolacion.get()

        mapa = {
            "INTER_NEAREST": cv2.INTER_NEAREST,
            "INTER_LINEAR": cv2.INTER_LINEAR,
            "INTER_CUBIC": cv2.INTER_CUBIC,
            "INTER_AREA": cv2.INTER_AREA
        }
        return mapa.get(nombre, cv2.INTER_LINEAR)

    # ---------------------------------------------------------
    # TRANSFORMACIONES GEOMÉTRICAS
    # ---------------------------------------------------------
    def redimensionar(self):
        if not self.verificar_imagen():
            return

        try:
            ancho = int(self.entry_ancho.get())
            alto = int(self.entry_alto.get())

            if ancho <= 0 or alto <= 0:
                raise ValueError

            interpolacion = self.obtener_interpolacion()
            self.imagen_actual = cv2.resize(self.imagen_actual, (ancho, alto), interpolation=interpolacion)
            self.actualizar_vista_procesada()

        except ValueError:
            messagebox.showerror("Error", "Ingrese un ancho y alto válidos.")

    def redimensionar_proporcion(self):
        if not self.verificar_imagen():
            return

        try:
            nuevo_ancho = int(self.entry_ancho.get())
            if nuevo_ancho <= 0:
                raise ValueError

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

            interpolacion = self.obtener_interpolacion()
            self.imagen_actual = cv2.resize(
                self.imagen_actual,
                (nuevo_ancho, nuevo_alto),
                interpolation=interpolacion
            )
            self.actualizar_vista_procesada()

        except ValueError:
            messagebox.showerror("Error", "Ingrese un ancho válido.")

    def trasladar(self):
        if not self.verificar_imagen():
            return

        try:
            tx = int(self.entry_tx.get())
            ty = int(self.entry_ty.get())

            alto, ancho = self.imagen_actual.shape[:2]
            M = np.float32([[1, 0, tx],
                            [0, 1, ty]])

            self.imagen_actual = cv2.warpAffine(
                self.imagen_actual,
                M,
                (ancho, alto),
                borderValue=(0, 0, 0)
            )
            self.actualizar_vista_procesada()

        except ValueError:
            messagebox.showerror("Error", "Ingrese desplazamientos válidos.")

    def rotar(self):
        if not self.verificar_imagen():
            return

        alto, ancho = self.imagen_actual.shape[:2]
        centro = (ancho // 2, alto // 2)

        angulo = float(self.scale_angulo.get())
        escala = float(self.scale_escala_rot.get())

        M = cv2.getRotationMatrix2D(centro, angulo, escala)

        self.imagen_actual = cv2.warpAffine(
            self.imagen_actual,
            M,
            (ancho, alto),
            flags=self.obtener_interpolacion(),
            borderValue=(0, 0, 0)
        )
        self.actualizar_vista_procesada()

    def rotar_sin_recorte(self):
        if not self.verificar_imagen():
            return

        imagen = self.imagen_actual
        alto, ancho = imagen.shape[:2]
        centro = (ancho / 2, alto / 2)

        angulo = float(self.scale_angulo.get())
        escala = float(self.scale_escala_rot.get())

        M = cv2.getRotationMatrix2D(centro, angulo, escala)

        coseno = abs(M[0, 0])
        seno = abs(M[0, 1])

        nuevo_ancho = int((alto * seno) + (ancho * coseno))
        nuevo_alto = int((alto * coseno) + (ancho * seno))

        M[0, 2] += (nuevo_ancho / 2) - centro[0]
        M[1, 2] += (nuevo_alto / 2) - centro[1]

        self.imagen_actual = cv2.warpAffine(
            imagen,
            M,
            (nuevo_ancho, nuevo_alto),
            flags=self.obtener_interpolacion(),
            borderValue=(0, 0, 0)
        )
        self.actualizar_vista_procesada()

    def flip_horizontal(self):
        if not self.verificar_imagen():
            return
        self.imagen_actual = cv2.flip(self.imagen_actual, 1)
        self.actualizar_vista_procesada()

    def flip_vertical(self):
        if not self.verificar_imagen():
            return
        self.imagen_actual = cv2.flip(self.imagen_actual, 0)
        self.actualizar_vista_procesada()

    def flip_ambos(self):
        if not self.verificar_imagen():
            return
        self.imagen_actual = cv2.flip(self.imagen_actual, -1)
        self.actualizar_vista_procesada()

    def recorte_central(self):
        if not self.verificar_imagen():
            return

        imagen = self.imagen_actual
        alto, ancho = imagen.shape[:2]

        porc_x = self.scale_crop_x.get() / 100.0
        porc_y = self.scale_crop_y.get() / 100.0

        nuevo_ancho = int(ancho * porc_x)
        nuevo_alto = int(alto * porc_y)

        if nuevo_ancho <= 0 or nuevo_alto <= 0:
            messagebox.showerror("Error", "El tamaño del recorte no es válido.")
            return

        x1 = (ancho - nuevo_ancho) // 2
        y1 = (alto - nuevo_alto) // 2
        x2 = x1 + nuevo_ancho
        y2 = y1 + nuevo_alto

        self.imagen_actual = imagen[y1:y2, x1:x2]
        self.actualizar_vista_procesada()

    def transformacion_afin(self):
        if not self.verificar_imagen():
            return

        imagen = self.imagen_actual
        alto, ancho = imagen.shape[:2]

        pts1 = np.float32([
            [0.15 * ancho, 0.15 * alto],
            [0.85 * ancho, 0.15 * alto],
            [0.15 * ancho, 0.85 * alto]
        ])

        pts2 = np.float32([
            [0.05 * ancho, 0.25 * alto],
            [0.90 * ancho, 0.10 * alto],
            [0.25 * ancho, 0.90 * alto]
        ])

        M = cv2.getAffineTransform(pts1, pts2)

        self.imagen_actual = cv2.warpAffine(
            imagen,
            M,
            (ancho, alto),
            flags=self.obtener_interpolacion(),
            borderValue=(0, 0, 0)
        )
        self.actualizar_vista_procesada()

    def transformacion_perspectiva(self):
        if not self.verificar_imagen():
            return

        imagen = self.imagen_actual
        alto, ancho = imagen.shape[:2]

        pts1 = np.float32([
            [0.15 * ancho, 0.10 * alto],
            [0.85 * ancho, 0.15 * alto],
            [0.10 * ancho, 0.90 * alto],
            [0.90 * ancho, 0.85 * alto]
        ])

        pts2 = np.float32([
            [0, 0],
            [ancho - 1, 0],
            [0, alto - 1],
            [ancho - 1, alto - 1]
        ])

        M = cv2.getPerspectiveTransform(pts1, pts2)

        self.imagen_actual = cv2.warpPerspective(
            imagen,
            M,
            (ancho, alto),
            flags=self.obtener_interpolacion(),
            borderValue=(0, 0, 0)
        )
        self.actualizar_vista_procesada()

    def ejemplo_integrado(self):
        if not self.verificar_imagen():
            return

        imagen = self.imagen_actual.copy()
        alto, ancho = imagen.shape[:2]

        # 1) Escalado moderado
        imagen = cv2.resize(
            imagen,
            (int(ancho * 0.8), int(alto * 0.8)),
            interpolation=self.obtener_interpolacion()
        )

        alto2, ancho2 = imagen.shape[:2]
        centro = (ancho2 // 2, alto2 // 2)

        # 2) Rotación
        M_rot = cv2.getRotationMatrix2D(centro, 20, 1.0)
        imagen = cv2.warpAffine(
            imagen,
            M_rot,
            (ancho2, alto2),
            flags=self.obtener_interpolacion(),
            borderValue=(0, 0, 0)
        )

        # 3) Traslación
        M_tras = np.float32([[1, 0, 40],
                             [0, 1, 20]])
        imagen = cv2.warpAffine(
            imagen,
            M_tras,
            (ancho2, alto2),
            flags=self.obtener_interpolacion(),
            borderValue=(0, 0, 0)
        )

        self.imagen_actual = imagen
        self.actualizar_vista_procesada()


if __name__ == "__main__":
    root = tk.Tk()
    app = AplicacionTransformacionesGeometricas(root)
    root.mainloop()

Este programa reúne en una sola interfaz varias transformaciones geométricas y permite probarlas interactivamente sobre imágenes del directorio actual o cargadas por el usuario.

12.24 Errores comunes

Al trabajar con transformaciones geométricas suelen aparecer errores frecuentes:

  • Confundir el orden de ancho y alto.
  • No ajustar el tamaño de salida y perder contenido.
  • Olvidar la interpolación al escalar o rotar.
  • Aplicar la transformación a la imagen pero no a las anotaciones.
  • Deformar la imagen sin querer al no conservar proporciones.
  • Usar una transformación demasiado agresiva para el problema.

Estos errores afectan tanto la calidad visual como la utilidad del resultado para algoritmos posteriores.

12.25 Qué debes recordar de este tema

  • Las transformaciones geométricas modifican la posición espacial de los píxeles.
  • Las más importantes son escalado, traslación, rotación, flip, transformaciones afines y de perspectiva.
  • OpenCV implementa estas operaciones principalmente con resize, warpAffine y warpPerspective.
  • La interpolación es clave cuando la nueva geometría no coincide exactamente con la grilla original.
  • Estas transformaciones son muy útiles en preprocesamiento, corrección geométrica y data augmentation.
  • Cuando hay anotaciones, deben transformarse junto con la imagen.

12.26 Conclusión

Las transformaciones geométricas permiten reorganizar el contenido visual de una imagen para hacerlo más útil, más comparable o más adecuado para un algoritmo posterior. Son herramientas básicas, pero extremadamente potentes en aplicaciones prácticas.

Entender cómo se desplazan, rotan o deforman las coordenadas es un paso importante para pensar en visión por computadora con mayor profundidad matemática y operativa.

En el próximo tema estudiaremos el filtrado y suavizado de imágenes, que nos llevará desde la geometría hacia técnicas que modifican la información local de cada región para reducir ruido o resaltar estructuras.