0%
Visión por computador

Visión

Cómo extrae información de una imagen una máquina.

10-15 min

Visión por computador

Reconocer un gato en una foto es, para un humano, una operación instantánea e inconsciente. Es también el problema que más tiempo se ha resistido a la informática. Durante décadas, los investigadores escribieron a mano detectores de contornos, de esquinas y de texturas, y apilaron reglas para combinarlos, sin acercarse nunca a la robustez de un niño de tres años.

La dificultad no es la falta de información, sino su exceso. Una fotografía de móvil contiene varios millones de números, ninguno de los cuales significa nada por sí solo. El valor de un píxel no dice nada; es la organización local de miles de píxeles la que forma un contorno, y la organización de miles de contornos la que forma un rostro. Y esa organización debe reconocerse sea cual sea la posición del objeto, su iluminación, su escala o su orientación.

La idea que desbloqueó el problema es sorprendentemente económica. En lugar de aprender una regla para cada posición posible de la imagen, se aprende un pequeño detector y se pasea por todas partes. Un patrón aprendido una vez se reconoce dondequiera que esté, y el número de parámetros que hay que ajustar se desploma. Esta operación se llama convolución, y vamos a escribirla nosotros mismos en una decena de líneas.

Veremos después cómo se apilan esos detectores, en qué se diferencian realmente las tres grandes tareas de la visión y por qué hoy casi nadie entrena un modelo de visión desde cero.

Una imagen es una tabla de números

Una imagen en escala de grises es una matriz: un valor de intensidad por píxel. Una imagen en color son tres matrices superpuestas, una por cada canal rojo, verde y azul.

from sklearn.datasets import load_digits

chiffres = load_digits()
print(chiffres.images.shape)   # (1797, 8, 8)
print(chiffres.images.min(), chiffres.images.max())   # 0.0 16.0
print(chiffres.target[0])      # 0

print(chiffres.images[0].astype(int))
# [[ 0  0  5 13  9  1  0  0]
#  [ 0  0 13 15 10 15  5  0]
#  [ 0  3 15  2  0 11  8  0]
#  [ 0  4 12  0  0  8  8  0]
#  [ 0  5  8  0  0  9  8  0]
#  [ 0  4 11  0  1 12  7  0]
#  [ 0  2 14  5 10 12  0  0]
#  [ 0  0  6 13 10  0  0  0]]

Si entornamos los ojos, se adivina el cero: dos columnas de valores altos separadas por un hueco central de ceros. Es exactamente lo que recibe la máquina, ni más ni menos.

La escala es el primer obstáculo. Este conjunto de datos usa miniaturas de 8 × 8, es decir, 64 números por imagen. Una fotografía de entrada habitual para una red de visión mide 224 × 224 píxeles con 3 canales:

print(224 * 224 * 3)   # 150528

150 528 números para una sola imagen. Todo lo que sigue consiste en tratar este volumen sin hacer explotar el número de parámetros que hay que aprender.

La convolución: detectar un patrón, esté donde esté

Una convolución desliza una pequeña matriz de pesos, el núcleo (o filtro), sobre la imagen. En cada posición, multiplica término a término y suma. El resultado es una nueva imagen, llamada mapa de activación, que indica dónde está presente el patrón buscado.

import numpy as np

# Una barra vertical clara sobre fondo oscuro.
image = np.array([
    [0, 0, 9, 9, 0, 0],
    [0, 0, 9, 9, 0, 0],
    [0, 0, 9, 9, 0, 0],
    [0, 0, 9, 9, 0, 0],
], dtype=float)

# Núcleo de Sobel simplificado: detector de contorno vertical.
noyau = np.array([[1.0, 0.0, -1.0],
                  [1.0, 0.0, -1.0],
                  [1.0, 0.0, -1.0]])

def convoluer(image, noyau):
    h, w = image.shape
    kh, kw = noyau.shape
    sortie = np.zeros((h - kh + 1, w - kw + 1))
    for y in range(sortie.shape[0]):
        for x in range(sortie.shape[1]):
            fenetre = image[y:y + kh, x:x + kw]
            sortie[y, x] = float((fenetre * noyau).sum())
    return sortie

carte = convoluer(image, noyau)
print(carte)
# [[-27. -27.  27.  27.]
#  [-27. -27.  27.  27.]]

Verifiquemos un valor a mano. Arriba a la derecha, la ventana cubre las columnas 3, 4 y 5, es decir, los valores [[9,0,0],[9,0,0],[9,0,0]]. El producto con el núcleo da 9×1 + 0×0 + 0×(−1) repetido en tres filas, o sea 27. El valor +27 señala una transición de claro a oscuro. A la izquierda, la misma transición en sentido inverso da −27. En el centro del patrón, un núcleo simétrico daría 0: nada que señalar.

Tres propiedades hacen toda la potencia de la operación:

  • Localidad. Cada salida depende solo de un pequeño vecindario, lo que se corresponde con la estructura real de las imágenes: un contorno es un fenómeno local.
  • Compartición de pesos. Los nueve pesos del núcleo son los mismos en todas las posiciones. Un detector aprendido una vez funciona en toda la imagen.
  • Economía. Es la consecuencia cuantificada del punto anterior, y es espectacular.
# Una capa densa que conecta todos los píxeles de una imagen 224×224×3 con 1000 neuronas:
print(150528 * 1000 + 1000)      # 150529000

# Una capa convolucional de 64 núcleos de 3×3 sobre 3 canales:
print(3 * 3 * 3 * 64 + 64)       # 1792

150 millones de parámetros frente a 1792. Un factor de aproximadamente 84 000. Sin la compartición de pesos, el aprendizaje profundo sobre imágenes nunca habría sido viable, y no es cuestión de paciencia: el número de ejemplos necesarios para ajustar 150 millones de parámetros en una sola capa está fuera de alcance.

Reducir, apilar y ver más amplio

Una convolución por sí sola solo mira un vecindario de 3 × 3. Para reconocer un objeto entero, hay que ampliar progresivamente el campo de visión. Dos mecanismos se encargan de ello.

El primero es el submuestreo (pooling), cuya forma más habitual conserva el máximo de cada bloque:

def pooling_max(carte, taille=2):
    h, w = carte.shape
    return carte[:h - h % taille, :w - w % taille].reshape(
        h // taille, taille, w // taille, taille).max(axis=(1, 3))

print(pooling_max(np.abs(carte)))   # [[27. 27.]]

El mapa de 2 × 4 pasa a ser de 1 × 2. Se pierde la posición exacta del contorno y se conserva su intensidad: es precisamente el compromiso buscado para la clasificación, donde saber que hay un contorno importa más que su posición al píxel.

El segundo mecanismo es el apilamiento. Cada capa ve las salidas de la anterior y, por tanto, un vecindario cada vez más amplio de la imagen original:

Capa 1  (3×3 píxeles vistos)    →  contornos, manchas de color
Capa 2  (~7×7 píxeles vistos)   →  esquinas, ángulos, curvas
Capa 3  (~15×15)                →  patrones: ojo, rueda, textura
Capa 4+ (gran parte)            →  partes de objetos y, después, objetos

Esta jerarquía no la impone el programador: emerge del entrenamiento, y se ha vuelto a encontrar experimentalmente en redes entrenadas visualizando qué activa más a cada filtro. Es el aprendizaje de representaciones visto en la lección anterior, aplicado a las imágenes.

Dos fechas estructuran esta historia: LeNet-5, publicada por Yann LeCun y colaboradores en 1998, ya aplica este esquema a la lectura de cheques; AlexNet, en 2012, lo lleva a la escala de ImageNet y desencadena la ola actual.

Clasificar, detectar, segmentar

Estas tres palabras designan tareas que a menudo se confunden. Se distinguen sin ambigüedad por la forma de su salida, que determina el coste de la anotación y la dificultad del problema.

TareaPreguntaSalida para una imagen 224×224 y 20 clases
Clasificación¿qué hay en esta imagen?20 números (una probabilidad por clase)
Detección¿dónde están los objetos y cuáles son?una lista: clase, puntuación y 4 coordenadas por objeto
Segmentación¿a qué clase pertenece cada píxel?224 × 224 × 20 = 1 003 520 números
print(224 * 224 * 20)   # 1003520

La diferencia entre 20 y un millón de valores de salida lo explica casi todo: la segmentación exige anotaciones al píxel, cuyo coste de producción no tiene comparación con una simple etiqueta de imagen.

En detección, la métrica de referencia mide el solapamiento entre la caja predicha y la caja de referencia. Se calcula exactamente:

def iou(a, b):
    """Intersección sobre unión de dos cajas (x1, y1, x2, y2)."""
    x1 = max(a[0], b[0]); y1 = max(a[1], b[1])
    x2 = min(a[2], b[2]); y2 = min(a[3], b[3])
    inter = max(0, x2 - x1) * max(0, y2 - y1)
    aire_a = (a[2] - a[0]) * (a[3] - a[1])
    aire_b = (b[2] - b[0]) * (b[3] - b[1])
    return inter / (aire_a + aire_b - inter)

print(round(iou((10, 10, 50, 50), (30, 30, 70, 70)), 3))   # 0.143
print(round(iou((10, 10, 50, 50), (12, 12, 52, 52)), 3))   # 0.822

Primer caso: dos cajas de 40 × 40 desplazadas 20 píxeles se solapan en 20 × 20 = 400 píxeles, para una unión de 1600 + 1600 − 400 = 2800, es decir, 400 / 2800 ≈ 0,143. La convención habitual considera correcta una detección a partir de 0,5. La segunda caja, desplazada solo 2 píxeles, se solapa en 38 × 38 = 1444 píxeles para una unión de 1756, es decir, 0,822: correcta.

La transferencia de aprendizaje

Entrenar una red de visión desde cero requiere muchas imágenes anotadas (ImageNet aporta alrededor de 1,2 millones para 1000 categorías) y un presupuesto de cómputo considerable. Casi nadie dispone de eso para un problema de negocio concreto: clasificar piezas defectuosas, clasificar radiografías, reconocer una especie vegetal.

La transferencia de aprendizaje resuelve este problema en dos pasos. Se parte de una red ya entrenada con un gran corpus, se congelan sus capas (han aprendido detectores de contornos, texturas y patrones que siguen siendo pertinentes para otras imágenes) y solo se reentrena la última capa, la que decide.

El ahorro se puede cuantificar. Con un backbone que produce un vector de 2048 características y 10 clases objetivo, la capa que hay que aprender tiene 2048 × 10 + 10 = 20 490 parámetros, en lugar de las decenas de millones de la red completa.

Falta comprobar si el principio se verifica. Pongámoslo a prueba con honestidad: «preentrenamos» una representación con los dígitos del 5 al 9 y luego aprendemos a reconocer los dígitos del 0 al 4 con solo 5 ejemplos por clase.

import numpy as np
from sklearn.datasets import load_digits
from sklearn.decomposition import PCA
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split

X, y = load_digits(return_X_y=True)

source = X[y >= 5]                       # «preentrenamiento»: clases 5 a 9
cible_X, cible_y = X[y < 5], y[y < 5]    # tarea objetivo: clases 0 a 4

Xp, Xt, yp, yt = train_test_split(cible_X, cible_y, test_size=0.4,
                                  random_state=0, stratify=cible_y)

indices = np.concatenate([np.where(yp == c)[0][:5] for c in range(5)])

brut = LogisticRegression(max_iter=5000).fit(Xp[indices], yp[indices])
print(round(brut.score(Xt, yt), 3))                       # 0.936

# La representación se aprende solo con las clases 5-9,
# sin ver nunca una etiqueta de la tarea objetivo.
pca = PCA(n_components=30, random_state=0).fit(source)
tete = LogisticRegression(max_iter=5000).fit(pca.transform(Xp[indices]), yp[indices])
print(round(tete.score(pca.transform(Xt), yt), 3))         # 0.931

El resultado merece un comentario franco: la representación transferida no mejora a los píxeles en bruto (0,931 frente a 0,936). El experimento no demuestra el beneficio de la transferencia, y sería deshonesto presentarlo como si lo hiciera.

Este resultado negativo es instructivo. La transferencia no aporta nada aquí por tres razones identificables: una miniatura de 8 × 8 solo contiene 64 valores, ya aprovechables directamente; el análisis de componentes principales es una transformación lineal, incapaz de construir la jerarquía de patrones que da valor a un backbone convolucional; y 30 componentes sobre 64 dimensiones apenas comprimen nada. La transferencia produce las ganancias documentadas cuando el backbone es profundo, no lineal y está preentrenado con imágenes mucho más ricas que estas miniaturas. La mecánica mostrada arriba (congelar la representación y aprender solo la cabeza) es la que se emplea en la práctica; lo que no es la práctica es la escala que la hace rentable.

Lo esencial

  • Una imagen es una tabla de números, y el problema es su cantidad: 150 528 valores para una sola fotografía en color de 224 × 224.
  • La convolución pasea un pequeño núcleo de pesos compartidos por toda la imagen. Esa compartición es lo que hace viable el asunto: 1792 parámetros en lugar de 150 millones para una capa densa equivalente.
  • Submuestrear y apilar capas amplía progresivamente el campo de visión y hace emerger una jerarquía (contornos, patrones, partes de objetos) que nadie ha especificado.
  • Clasificación, detección y segmentación se diferencian por la forma de su salida, de 20 números a más de un millón. El coste de la anotación sigue la misma progresión.
  • La transferencia de aprendizaje consiste en congelar un backbone preentrenado y reentrenar solo la capa de decisión, es decir, unos 20 000 parámetros en lugar de millones. Su beneficio depende de la riqueza del backbone: con miniaturas de 8 × 8 y una transformación lineal, el experimento anterior no muestra ninguno.

Comentarios

Los comentarios funcionan con GitHub Discussions

Inicia sesión con GitHub para participar en la discusión

¡Enlace copiado!