Transformaciones de imágenes afines en Python con Numpy, Pillow y OpenCV

En este artículo describiré lo que significa aplicar una transformación afín a una imagen y cómo hacerlo en Python. Primero demostraré el bajo le...

En este artículo describiré lo que significa aplicar una transformación afín a una imagen y cómo hacerlo en Python. Primero demostraré las operaciones de bajo nivel en entumecido para dar una implementación geométrica detallada. Luego los guiaré hacia un uso más práctico de las bibliotecas Python Almohada y OpenCV.

Este artículo fue escrito usando un cuaderno Jupyter y la fuente se puede encontrar en mi repositorio de GitHub así que siéntete libre de clonarlo/bifurcarlo y experimentar con el código.

¿Qué es una transformación afín?

De acuerdo con Wikipedia, una transformación afín es un mapeo funcional entre dos espacios geométricos (afines) que conservan puntos, líneas rectas y paralelas, así como proporciones entre puntos. Toda esa redacción matemática abstracta se reduce a una transformación lineal que, en términos generales, da como resultado, al menos en el contexto del procesamiento de imágenes, una o más manipulaciones como rotar, voltear, escalar o cortar mediante la aplicación de una matriz de transformación.

Una cosa buena es que, dado que se trata esencialmente de una operación geométrica en 2D, podemos visualizarla. Permítanme comenzar dando una tabla de transformaciones afines que describen cada tipo de manipulación geométrica.


Tipo de transformación Matriz de transformación Ecuación de mapeo de píxeles


Identidad $$ \begin{bmatrix} 1 & 0 & 0 \\ 0 & 1 & 0 \\ 0 & 0 & 1 \end{bmatrix} $$ $$x\ ^{'} = x$$
$$y^{'} = y$$

Escalado $$ \begin{bmatrix} c_{x} & 0 & 0 \\ 0 & c_{y} & 0 \\ 0 & 0 & 1 \end{bmatrix} $$ $$x^{'} = c_{x} * x$$
$$y^{'} = c_{y} * y$$

Rotación* $$ \begin{bmatrix} cos \Theta & sin \Theta & 0 \\ -sin \Theta & cos \Theta & 0 \\ 0 & 0 & 1 \ \end{bmatriz} $$ $$x^{'} = x * cos \Theta - y * sin \Theta$$
$$y^{'} = x * cos \Theta + y * sin \Theta$$

Traducción $$ \begin{bmatrix} 1 & 0 & t_{x} \\ 0 & 1 & t_{y} \\ 0 & 0 & 1 \end{bmatrix} $$ $$x^{'} = x + t_{x}$$
$$y^{'} = y + t_{y}$$

Corte horizontal $$ \begin{bmatrix} 1 & s_{h} & ​​0 \\ 0 & 1 & 0 \\ 0 & 0 & 1 \end{bmatrix} $$ $$x^{'} = x + s_{v} * y$$
$$y^{'} = y$$

Cortante vertical $$ \begin{bmatrix} 1 & 0 & 0 \\ s_{v} & 1 & 0 \\ 0 & 0 & 1 \end{bmatrix} $$ $$x^{'} = x$$
$$y^{'} = x * s_{h} + y$$


* La transformación afín utiliza un ángulo de rotación en el sentido de las agujas del reloj, lo que contrasta con el círculo unitario geométrico típico de ángulos que se miden en rotación en el sentido contrario a las agujas del reloj con 0 a partir del eje X positivo, por lo tanto, verá que el negativo del ángulo es aplicado a menudo.

La notación ' aquí solo se refiere a la coordenada de salida transformada de x o y, no a la notación de cálculo para una derivada

Para una demostración simple, aplicaré un par de transformaciones para manipular las coordenadas x e y de los siguientes puntos que tienen componentes tridimensionales de x, y y un índice de caracteres ascii similar a la forma en que un píxel de imagen tiene componentes tridimensionales de x, y y frecuencia (o intensidad).

a = (0, 1, 0)
b = (1, 0, 1)
c = (0, -1, 2)
re = (-1, 0, 3)

Las transformaciones para este ejemplo serán Escalado por 2 en todas las direcciones y rotación de 90 grados en el sentido de las agujas del reloj. Primero realizaré las transformaciones individualmente para mostrar el efecto directo que cada una tiene al mover los puntos, luego combinaré las transformaciones y las aplicaré en una sola acción.

Para comenzar, quiero construir una matriz Numpy (algunos pueden llamar a esto una matriz) con cada fila representando el punto donde la primera columna es la x, la segunda la y, y la tercera es el índice de su letra en el juego de caracteres ascii similar a la tabla que se muestra a continuación. A continuación, uso matplotlib para trazar los puntos (después de aplicar la transformación de identidad invariable) para dar una línea de base visual de dónde nos encontramos.

Punto x (fila) y (columna) índice ascii


un 0 1 0 segundo 1 0 1 c 0 -1 2 d -1 0 3

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import matplotlib.pyplot as plt
import numpy as np
import string

# points a, b and, c
a, b, c, d = (0, 1, 0), (1, 0, 1), (0, -1, 2), (-1, 0, 3)

# matrix with row vectors of points
A = np.array([a, b, c, d])

# 3x3 Identity transformation matrix
I = np.eye(3)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
color_lut = 'rgbc'
fig = plt.figure()
ax = plt.gca()
xs = []
ys = []
for row in A:
    output_row = I @ row
    x, y, i = output_row
    xs.append(x)
    ys.append(y)
    i = int(i) # convert float to int for indexing
    c = color_lut[i]
    plt.scatter(x, y, color=c)
    plt.text(x + 0.15, y, f"{string.ascii_letters[i]}")
xs.append(xs[0])
ys.append(ys[0])
plt.plot(xs, ys, color="gray", linestyle='dotted')
ax.set_xticks(np.arange(-2.5, 3, 0.5))
ax.set_yticks(np.arange(-2.5, 3, 0.5))
plt.grid()
plt.show()

png

Los tres puntos a, b y c trazados en una cuadrícula después de aplicarles la transformación de Identidad a través de un producto escalar de matriz vectorial simple dejándolos sin cambios.

Ahora pasaré a crear una matriz de transformación de escala \(T_s\) , como se muestra a continuación, que escala la ubicación de los puntos en todas las direcciones.

$$ T_s = \begin{bmatriz} 2 & 0 & 0 \\ 0 & 2 & 0 \\ 0 & 0 & 1 \end{bmatriz} $$

Ahora pasaré a graficar los puntos transformados de manera similar a lo que se hizo con los puntos originales inalterados por la transformación de Identidad, pero esta vez aplicaré la matriz de transformación de escala definida anteriormente. Para una mejor visualización, trazo una línea de puntos que conecta los puntos.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# create the scaling transformation matrix
T_s = np.array([[2, 0, 0], [0, 2, 0], [0, 0, 1]])

fig = plt.figure()
ax = plt.gca()
xs_s = []
ys_s = []
for row in A:
    output_row = T_s @ row
    x, y, i = row
    x_s, y_s, i_s = output_row
    xs_s.append(x_s)
    ys_s.append(y_s)
    i, i_s = int(i), int(i_s) # convert float to int for indexing
    c, c_s = color_lut[i], color_lut[i_s] # these are the same but, its good to be explicit
    plt.scatter(x, y, color=c)
    plt.scatter(x_s, y_s, color=c_s)
    plt.text(x + 0.15, y, f"{string.ascii_letters[int(i)]}")
    plt.text(x_s + 0.15, y_s, f"{string.ascii_letters[int(i_s)]}'")

xs_s.append(xs_s[0])
ys_s.append(ys_s[0])
plt.plot(xs, ys, color="gray", linestyle='dotted')
plt.plot(xs_s, ys_s, color="gray", linestyle='dotted')
ax.set_xticks(np.arange(-2.5, 3, 0.5))
ax.set_yticks(np.arange(-2.5, 3, 0.5))
plt.grid()
plt.show()

png

Del gráfico anterior debería quedar muy claro que las dimensiones x e y simplemente se escalaron por un factor de dos, mientras que la tercera dimensión responsable del índice de letras ASCII no se modificó. De hecho, aquellos familiarizados con el álgebra matricial habrán notado que para todas las transformaciones afines enumeradas en la primera tabla, el valor representado en la tercera dimensión siempre se deja sin modificar, como lo indican los ceros y un solo valor en la tercera dimensión. índice de la última columna.

Ahora déjame describir cómo interpretar la transformación de rotación. Comenzaré resolviendo las dos funciones trigonométricas para el ángulo de rotación deseado de 90 grados, luego simplemente las conectaré a la matriz de transformación de rotación enumerada en la tabla anterior.

$$
sin (90^{o}) = 1
$$

$$
coseno (90^{o}) = 0
$$

$$ T_r = \begin{bmatriz} 0 & 1 & 0 \\ -1 & 0 & 0 \\ 0 & 0 & 1 \end{bmatriz} $$

Ahora todo lo que necesito hacer es aplicar la misma lógica para transformar y trazar los puntos, así:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# create the rotation transformation matrix
T_r = np.array([[0, 1, 0], [-1, 0, 0], [0, 0, 1]])

fig = plt.figure()
ax = plt.gca()
for row in A:
    output_row = T_r @ row
    x_r, y_r, i_r = output_row
    i_r = int(i_r) # convert float to int for indexing
    c_r = color_lut[i_r] # these are the same but, its good to be explicit
    letter_r = string.ascii_letters[i_r]
    plt.scatter(x_r, y_r, color=c_r)
    plt.text(x_r + 0.15, y_r, f"{letter_r}'")

plt.plot(xs, ys, color="gray", linestyle='dotted')
ax.set_xticks(np.arange(-2.5, 3, 0.5))
ax.set_yticks(np.arange(-2.5, 3, 0.5))
plt.grid()
plt.show()

png

Con suerte, podrá ver en la gráfica que todos los puntos rotaron 90 grados alrededor de un eje de rotación en el origen.

Lo bueno de que las transformaciones afines sean esencialmente transformaciones lineales es que puede combinar las transformaciones y aplicarlas en un solo paso. Para demostrar esto, aplicaré el producto escalar (multiplicación de matrices) de mis dos matrices de transformación, como:

$$ T_{comb} = \begin{bmatrix} 0 & 1 & 0 \\ -1 & 0 & 0 \\ 0 & 0 & 1 \end{bmatrix} * \ \begin{bmatrix} 2 & 0 & 0 \\ 0 & 2 & 0 \\ 0 & 0 & 1 \end{bmatrix} = \begin{bmatrix} 0 & 2 & 0 \\ -2 & 0 & 0 \\ 0 & 0 & 1 \end{bmatriz} $$

Ahora puedo aplicar esta matriz de transformación combinada a los puntos y volver a trazarlos para mostrar una combinación de escalado por dos y rotación de 90 grados.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# create combined tranformation matrix
T = T_s @ T_r

fig = plt.figure()
ax = plt.gca()

xs_comb = []
ys_comb = []
for row in A:
    output_row = T @ row
    x, y, i = row
    x_comb, y_comb, i_comb = output_row
    xs_comb.append(x_comb)
    ys_comb.append(y_comb)
    i, i_comb = int(i), int(i_comb) # convert float to int for indexing
    c, c_comb = color_lut[i], color_lut[i_comb] # these are the same but, its good to be explicit
    letter, letter_comb = string.ascii_letters[i], string.ascii_letters[i_comb]
    plt.scatter(x, y, color=c)
    plt.scatter(x_comb, y_comb, color=c_comb)
    plt.text(x + 0.15 , y, f"{letter}")
    plt.text(x_comb + 0.15, y_comb, f"{letter_comb}'")
xs_comb.append(xs_comb[0])
ys_comb.append(ys_comb[0])
plt.plot(xs, ys, color="gray", linestyle='dotted')
plt.plot(xs_comb, ys_comb, color="gray", linestyle='dotted')
ax.set_xticks(np.arange(-2.5, 3, 0.5))
ax.set_yticks(np.arange(-2.5, 3, 0.5))
plt.grid()
plt.show()

png

Trabajar con una imagen

A estas alturas, espero haber podido desarrollar cierta intuición sobre cómo se usan las transformaciones afines para simplemente mover puntos en el espacio 2D, por lo que, con eso fuera del camino, me gustaría comenzar a trabajar con una imagen real. datos para dar una demostración más concreta de cómo funciona todo esto.

Esto también me permite cubrir otro tema importante de transformaciones afines que trata con la tercera dimensión. La tercera dimensión de los datos en una imagen representa el valor real del píxel, o a veces se denomina dominio de intensidad, mientras que la ubicación física 2D de los píxeles en las otras dos dimensiones se denomina dominio espacial.

Para comenzar, leeré y mostraré una imagen usando matplotlib, que es simplemente una letra R mayúscula grande.

1
2
img = plt.imread('letterR.jpg')
img.shape #  (1000, 1000, 4)

Usando el método imread(...) puedo leer en la imagen JPG, que representa la letra mayúscula R, en un ndarray numpy. Luego muestro las dimensiones de la matriz, que son 1000 filas por 1000 columnas, que juntas forman 1 000 000 de ubicaciones de píxeles en el dominio espacial. Los datos de píxel individuales tienen entonces la forma de una matriz de 4 enteros sin signo que representan un canal (o muestra) rojo, verde, azul y alfa que juntos proporcionan los datos de intensidad de cada píxel.

1
2
plt.figure(figsize=(5, 5))
plt.imshow(img)

png

A continuación, me gustaría aplicar la escala y la rotación anteriores al dominio espacial de los datos de la imagen, transformando así las ubicaciones de los píxeles de manera similar a lo que demostré anteriormente con los datos de puntos. Sin embargo, necesito adoptar un enfoque ligeramente diferente porque los datos de la imagen están organizados de una manera diferente a la de las filas de puntos de datos con los que trabajé anteriormente. Con los datos de la imagen, necesito asignar los índices de cada píxel de los datos de entrada a los índices de salida transformados utilizando la matriz de transformación T, definida anteriormente.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 2x scaling requires a tranformation image array 2x the original image
img_transformed = np.empty((2000, 2000, 4), dtype=np.uint8)
for i, row in enumerate(img):
    for j, col in enumerate(row):
        pixel_data = img[i, j, :]
        input_coords = np.array([i, j, 1])
        i_out, j_out, _ = T @ input_coords
        img_transformed[i_out, j_out, :] = pixel_data

plt.figure(figsize=(5, 5))
plt.imshow(img_transformed)

png

Trazar la imagen después de aplicar la transformación muestra claramente que la imagen original se ha girado 90 grados en el sentido de las agujas del reloj y se ha ampliado 2X. Sin embargo, el resultado ahora obviamente se ve disminuido, ya que puede ver fácilmente la discontinuidad en las intensidades de los píxeles.

Para comprender la razón de esto, volveré a utilizar un diagrama de cuadrícula simple para la demostración. Considere un gráfico de 4 cuadrados en una cuadrícula de 2x2 similar al dominio espacial de una imagen de 2x2.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
def plot_box(plt, x0, y0, txt, w=1, h=1):
    plt.scatter(x0, y0)
    plt.scatter(x0, y0 + h)
    plt.scatter(x0 + w, y0 + h)
    plt.scatter(x0 + w, y0)
    plt.plot([x0, x0, x0 + w, x0 + w, x0], [y0, y0 + h, y0 + h, y0, y0], color="gray", linestyle='dotted')
    plt.text(x0 + (.33 * w), y0 + (.5 * h), txt)

#             x0, y0, letter
a = np.array((0,  1,  0))
b = np.array((1,  1,  1))
c = np.array((0,  0,  2))
d = np.array((1,  0,  3))

A = np.array([a, b, c, d])
fig = plt.figure()
ax = plt.gca()
for pt in A:
    x0, y0, i = I @ pt
    x0, y0, i = int(x0), int(y0), int(i)
    plot_box(plt, x0, y0, f"{string.ascii_letters[int(i)]} ({x0}, {y0})")

ax.set_xticks(np.arange(-1, 5, 1))
ax.set_yticks(np.arange(-1, 5, 1))
plt.grid()
plt.show()

png

Ahora mire lo que sucede cuando aplico una transformación de escala 2X como se muestra a continuación. Recordar que:

$$ T_s = \begin{bmatriz} 2 & 0 & 0 \\ 0 & 2 & 0 \\ 0 & 0 & 1 \end{bmatriz} $$

Notarás que tal transformación espacial da como resultado... bueno, "lagunas" para decirlo en términos simples, lo que he hecho obvio trazando signos de interrogación junto con las coordenadas. La cuadrícula de 2x2 se transforma en una cuadrícula de 3x3 y los cuadrados originales se reubican en función de la transformación lineal aplicada. Esto significa que (0,0) * \(T_s\) sigue siendo (0,0) debido a sus propiedades como vector 0, pero todos los demás se escalan en dos, como (1,1) * \ (T_s\) -> (2,2).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
fig = plt.figure()
ax = plt.gca()
for pt in A:
    xt, yt, i = T_s @ pt
    xt, yt, i = int(xt), int(yt), int(i)
    plot_box(plt, xt, yt, f"{string.ascii_letters[i]}' ({xt}, {yt})")

delta_w, delta_h = 0.33, 0.5
plt.text(0 + delta_w, 1 + delta_h, "? (0, 1)")
plt.text(1 + delta_w, 0 + delta_h, "? (1, 0)")
plt.text(1 + delta_w, 1 + delta_h, "? (1, 1)")
plt.text(1 + delta_w, 2 + delta_h, "? (1, 2)")
plt.text(2 + delta_w, 1 + delta_h, "? (2, 1)")

ax.set_xticks(np.arange(-1, 5, 1))
ax.set_yticks(np.arange(-1, 5, 1))
plt.grid()
plt.show()

png

Queda la pregunta de ¿qué hacer con esos vacíos que se han introducido? Un pensamiento intuitivo sería simplemente buscar la respuesta en la imagen original. Da la casualidad de que si aplicamos la inversa de la transformación a una coordenada en la salida, obtendré la ubicación correspondiente de la entrada original.

En las operaciones matriciales, como el mapeo hacia atrás, se ve así:

$$ (x, y, 1) = T_s^{-1} * (x' y' 1) $$

donde x', y' son las coordenadas en la cuadrícula de 3x3 transformada anterior, específicamente la ubicación que falta, como (2, 1), \(T_s^{-1}\) (los valores reales se muestran a continuación ) es el inverso de la matriz de escala 2x \(T_s\) y x, y son las coordenadas que se encuentran en la cuadrícula original de 2x2.

$$ T_s^{-1} = \begin{bmatriz} 1/2 & 0 & 0 \\ 0 & 1/2 & 0 \\ 0 & 0 & 1 \end{bmatriz }^{-1} $$

Sin embargo, pronto se dará cuenta de que hay un pequeño problema que aún debe resolverse debido al hecho de que cada una de las coordenadas de la brecha se corresponde con valores fraccionarios del sistema de coordenadas 2x2. En el caso de los datos de imagen, no puedes tener realmente una fracción de un píxel. Esto será más claro con un ejemplo de asignación de la brecha (2, 1) al espacio original de 2x2, así:

$$ T_s^{-1} * (2, 1, 1) = (1, 1/2, 1) $$

En este caso, redondearé y' = 1/2 a 0 y diré que eso corresponde a (1, 0). En sentido general, este método de seleccionar un valor en la cuadrícula original de 2x2 para colocarlo en los espacios de la cuadrícula transformada de 3x3 se conoce como interpolación, y en este ejemplo específico estoy usando una versión simplificada del método de interpolación del vecino más cercano.

Ok, ahora volvamos a los datos de la imagen. Debe quedar bastante claro qué se debe hacer ahora para corregir esos espacios en la versión escalada y rotada de la letra R. Debo desarrollar una implementación de la interpolación del vecino más cercano basada en el mapeo hacia atrás, usando el inverso de la matriz de transformación T, de las coordenadas de píxeles en la imagen transformada para encontrar la coincidencia exacta o el vecino más cercano en la imagen original.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
T_inv = np.linalg.inv(T)

# nearest neighbors interpolation
def nearest_neighbors(i, j, M, T_inv):
    x_max, y_max = M.shape[0] - 1, M.shape[1] - 1
    x, y, _ = T_inv @ np.array([i, j, 1])
    if np.floor(x) == x and np.floor(y) == y:
        x, y = int(x), int(y)
        return M[x, y]
    if np.abs(np.floor(x) - x) < np.abs(np.ceil(x) - x):
        x = int(np.floor(x))
    else:
        x = int(np.ceil(x))
    if np.abs(np.floor(y) - y) < np.abs(np.ceil(y) - y):
        y = int(np.floor(y))
    else:
        y = int(np.ceil(y))
    if x > x_max:
        x = x_max
    if y > y_max:
        y = y_max
    return M[x, y,]

img_nn = np.empty((2000, 2000, 4), dtype=np.uint8)
for i, row in enumerate(img_transformed):
    for j, col in enumerate(row):
        img_nn[i, j, :] = nearest_neighbors(i, j, img, T_inv)

plt.figure(figsize=(5, 5))
plt.imshow(img_nn)

png

No está mal, ¿verdad?

Debo señalar que, en la mayoría de los casos, el método del vecino más cercano no será suficiente. Hay otros dos métodos de interpolación más comunes conocidos como interpolación bilineal y bicúbica que generalmente brindan resultados mucho mejores. Hablaré más sobre estos otros algoritmos de interpolación cuando presente las bibliotecas Pillow y OpenCV en las últimas secciones. El propósito de esta sección es solo construir una comprensión intuitiva de cómo funcionan las cosas.

Transformaciones afines con almohada {#transformaciones afines con almohada}

En esta sección, cubriré brevemente cómo usar la excelente biblioteca de procesamiento de imágenes de Python, Pillow, para realizar transformaciones afines.

En primer lugar, será necesario instalar Pillow. Usé pip para lograr esto, así:

1
$ pip install pillow

Ahora, el primer paso es importar la clase Imagen del módulo PIL (PIL es el nombre del módulo de Python asociado con Pillow) y leer mi imagen.

1
from PIL import Image

Para leer el nombre del archivo de imagen de muestra "letterR.jpg", llamo al método de clase Image.open(...), pasándole el nombre del archivo, que devuelve una instancia de la clase Imagen, que luego convertir a una matriz numpy y mostrar con matplotlib.

1
2
3
img = Image.open('letterR.jpg')
plt.figure(figsize=(5, 5))
plt.imshow(np.asarray(img))

png

La clase Imagen de Pillow tiene un método útil llamado transform(...) que le permite realizar transformaciones afines detalladas, pero hay algunas rarezas que debo discutir primero antes de pasar a una demostración. El método transform(...) comienza con dos parámetros requeridos que representan el tamaño como una tupla de alto y ancho, seguido del método de transformación a aplicar, que será Imagen.AFFINE en este caso .

Los parámetros restantes son argumentos de palabra clave opcionales que controlan cómo se realizará la transformación. En el caso de este ejemplo, usaré el parámetro data, que toma las dos primeras filas de una matriz de transformación afín.

Por ejemplo, la matriz de transformación de escala 2x con la que he estado trabajando recortada a solo las dos primeras filas se ve así:

$$ T_s = \begin{bmatriz} 2 & 0 & 0 \\ 0 & 2 & 0 \end{bmatriz} $$

El último parámetro que usaré con el método transform(...) es resample, que se usa para indicar el tipo de algoritmo de interpolación de píxeles que se aplicará de las opciones posibles de Image.NEAREST (más cercano vecino), Imagen.BILINEAR, o Imagen.BICUBIC. Esta elección a menudo variará dependiendo de la transformación que se aplique. Sin embargo, bilineal y bicúbico generalmente dan mejores resultados que el vecino más cercano, pero como ya se demostró en este ejemplo, el vecino más cercano funciona bastante bien.

Hay algunas peculiaridades que me sirvieron como verdaderas trampas la primera vez que usé el método Image.transform(...), particularmente en torno a la construcción de la matriz de transformación afín con la última fila extrañamente truncada. Por lo tanto, me gustaría pasar un tiempo analizando por qué las cosas funcionan de la manera en que lo hacen porque es un proceso.

Lo primero que debe suceder es que la imagen debe traducirse para que el origen (0, 0) esté en el medio de la imagen. En el caso de la imagen de 1000 x 1000 de la letra R en este ejemplo eso significa una traslación de -500 en la x y la y.

A continuación, muestro la matriz de transformación de traducción genérica \(T_{translate}\) y la que usaré en el ejemplo \(T_{neg500}\).

$$ T_{traducir} = \begin{bmatrix} 1 & 0 & t_x \\ 0 & 1 & t_y \\ 0 & 0 & 1 \end{bmatrix} $$

$$
T_{neg500} = \begin{bmatriz}
1 & 0 & -500 \\ 0 y 1 y -500 \\ 0 & 0 & 1
\end{bmatriz}
$$

Luego están las matrices de escala 2X \(T_{scale}\) y rotación de 90 grados \(T_{rotate}\) de antes. Sin embargo, la biblioteca de Pillow en realidad decidió usar ángulos geométricos estándar (es decir, en el sentido contrario a las agujas del reloj) en lugar de las rotaciones en el sentido de las agujas del reloj que describí anteriormente, por lo que los signos de las funciones sin se invierten. A continuación se muestran las matrices de transformación individuales resultantes.

$$ T_{rotar} = \begin{bmatrix} 0 & -1 & 0 \\ 1 & 0 & 0 \\ 0 & 0 & 1 \end{bmatrix} $\ ps

$$
T_{escala} = \begin{bmatriz}
2 & 0 & 0 \\ 0 & 2 & 0 \\ 0 & 0 & 1
\end{bmatriz}
$$

A continuación, se debe aplicar otra matriz de traducción que actúe para reposicionar el dominio espacial de los píxeles, esencialmente negando el primero que centró el origen. En este caso, necesito una traducción positiva de 1000 en x e y, donde 1000 proviene del doble del original porque se ha ampliado en dos.

$$ T_{pos1000} = \begin{bmatrix} 1 & 0 & 1000 \\ 0 & 1 & 1000 \\ 0 & 0 & 1 \end{bmatrix} $$

Estos constituyen los pasos de transformación individuales que se requieren, por lo que todo lo que queda es multiplicar las matrices en orden (es decir, de derecha a izquierda), así:

$$ T = T_{pos1000} * T_{rotar} * T_{escala} * T_{neg500} $$

Ok, entonces en realidad hay una última rareza. El método Image.transform(...) en realidad requiere que se suministre el inverso de la matriz de transformación al parámetro data como una matriz plana (o tupla) excluyendo la última fila.

$$ T_{inv} = T^{-1} $$

En el código todo esto funciona de la siguiente manera:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# recenter resultant image
T_pos1000 = np.array([
    [1, 0, 1000],
    [0, 1, 1000],
    [0, 0, 1]])
# rotate - opposite angle
T_rotate = np.array([
    [0, -1, 0],
    [1, 0, 0],
    [0, 0, 1]])
# scale
T_scale = np.array([
    [2, 0, 0],
    [0, 2, 0],
    [0, 0, 1]])
# center original to 0,0
T_neg500 = np.array([
    [1, 0, -500],
    [0, 1, -500],
    [0, 0, 1]])
T = T_pos1000 @ T_rotate @ T_scale @ T_neg500
T_inv = np.linalg.inv(T)
1
2
img_transformed = img.transform((2000, 2000), Image.AFFINE, data=T_inv.flatten()[:6], resample=Image.NEAREST)
plt.imshow(np.asarray(img_transformed))

png

Transformaciones afines con OpenCV2

Continuando, me gustaría describir brevemente cómo llevar a cabo estas transformaciones afines con la popular biblioteca de procesamiento de imágenes y visión por computadora OpenCV. Uso la palabra breve aquí porque es en gran medida lo mismo que se requiere en la demostración anterior usando Pillow.

Lo primero es lo primero, debe instalar así:

1
$ pip install opencv-python

Como mencioné anteriormente, existe una superposición significativa en la metodología entre el enfoque de Pillow y el uso de OpenCV. Por ejemplo, aún crea una matriz de transformación que primero centra la matriz de píxeles en el origen y solo usa las dos primeras filas de la matriz de transformación. La principal diferencia es que con OpenCV le da la matriz estándar en lugar de la inversa.

Entonces, con ese entendimiento establecido, pasaré al código comenzando con la importación del módulo opencv-python, que se llama cv2.

1
import cv2

Leer la imagen es tan simple como llamar al método cv2.imread(...), pasando el nombre del archivo como argumento. Esto devuelve los datos de la imagen en forma de una matriz numérica 3D, similar a cómo funciona matplotlib, pero los datos de píxeles en la tercera dimensión se componen de una matriz de canales en el orden de azul, verde, rojo en lugar de rojo, verde, azul, alfa como en el caso de lectura con matplotlib.

Por lo tanto, para trazar los datos de imagen numpy que se originan en la biblioteca OpenCV, se debe invertir el orden de los canales de píxeles. Afortunadamente, OpenCV proporciona un método de convencimiento cvtColor(...) que se puede usar para hacer esto como se muestra a continuación (aunque es probable que los puristas numpy sepan que img[:,:,::-1] hará el mismo).

1
2
img = cv2.imread('letterR.jpg')
plt.imshow(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))

png

Unos últimos elementos para mencionar son que OpenCV requiere que los datos en la matriz de transformación sean de tipo flotante de 32 bits en lugar del flotante predeterminado de 64 bits, así que asegúrese de convertir a 32 bits con numpy.float32 (...) . Además, la API de cv2.warpAffine(...) no brinda la capacidad de especificar qué tipo de algoritmo de interpolación de píxeles aplicar y no pude determinar a partir de los documentos qué se usa. Si lo sabe o lo descubre, publíquelo en los comentarios a continuación.

1
2
3
T_opencv = np.float32(T.flatten()[:6].reshape(2,3))
img_transformed = cv2.warpAffine(img, T_opencv, (2000, 2000))
plt.imshow(cv2.cvtColor(img_transformed, cv2.COLOR_BGR2RGB))

png

Conclusión

En este artículo, he cubierto qué es una transformación afín y cómo se puede aplicar al procesamiento de imágenes usando Python. Se usó numpy puro y matplotlib para dar una descripción intuitiva de bajo nivel de cómo funcionan las transformaciones afines. Concluí demostrando cómo se puede hacer lo mismo usando dos bibliotecas populares de Python, Pillow y OpenCV.

Gracias por leer y, como siempre, no se avergüence de comentar o criticar a continuación.

Recursos