Capítulo 12 de 21 11 secciones 13 min

Compartir

Dónde está y qué forma tiene, no solo qué es

Detectar es decir dónde. Segmentar es decir qué píxeles. Las dos construidas a mano sobre el clasificador que ya tienes, con IoU y supresión de no máximos.

Clasificar devuelve un número por imagen, detectar devuelve una caja y una clase por objeto, y segmentar devuelve una etiqueta por píxel. Detectar no es pasar el clasificador por trozos: hace falta decidir qué cajas se solapan, y eso son IoU y la supresión de no máximos 🧾

Hasta acá, en el capítulo 11, la red mira una imagen y dice qué es. Una imagen, un número.

En un almacén nadie te trae la foto de una caja centrada. Te trae la foto de un pasillo y quiere saber cuántas cajas hay y dónde. Eso es otra tarea 📦

Las tres tareas, en formas

import numpy as np
from sklearn.datasets import load_digits
from sklearn.linear_model import LogisticRegression

d = load_digits()
img = d.images[0]

mascara = (img > 5).astype(int)
print('clasificar -> 1 numero por imagen')
print('detectar   -> por objeto: fila, columna, alto, ancho y clase')
print('segmentar  -> una etiqueta por pixel:', mascara.size, 'etiquetas')
clasificar -> 1 numero por imagen
detectar   -> por objeto: fila, columna, alto, ancho y clase
segmentar  -> una etiqueta por pixel: 64 etiquetas

De un número a 64. Y en una foto de un celular, de un número a doce millones. Por eso segmentar cuesta tanto más que clasificar 🐣

Segmentar, en su versión más simple

print(mascara)
print('pixeles de tinta:', int(mascara.sum()), 'de', mascara.size)
[[0 0 0 1 1 0 0 0]
 [0 0 1 1 1 1 0 0]
 [0 0 1 0 0 1 1 0]
 [0 0 1 0 0 1 1 0]
 [0 0 1 0 0 1 1 0]
 [0 0 1 0 0 1 1 0]
 [0 0 1 0 1 1 0 0]
 [0 0 1 1 1 0 0 0]]
pixeles de tinta: 24 de 64

Ahí está el cero, dibujado con unos. Eso es una máscara, y segmentar es producirla.

Lo hice con un umbral, que es lo que se hacía antes de las redes y sigue funcionando cuando el fondo es limpio. El problema es que el umbral no se sostiene:

for u in (2, 5, 8, 12):
    print('  umbral', u, '-> pixeles de tinta', int((img > u).sum()))
  umbral 2 -> pixeles de tinta 31
  umbral 5 -> pixeles de tinta 24
  umbral 8 -> pixeles de tinta 17
  umbral 12 -> pixeles de tinta 7

De 31 a 7 según dónde lo pongas, y ese número es el que después alguien reporta como "área del defecto". Cambia la iluminación de la nave y cambia tu métrica.

Una red de segmentación aprende ese umbral sola y por zonas, que es lo único que hace distinto. La idea de "una etiqueta por píxel" es la misma.

Detectar: el intento ingenuo

Entreno el clasificador de siempre y monto una escena con dos dígitos:

modelo = LogisticRegression(max_iter=2000).fit(d.data, d.target)

lienzo = np.zeros((30, 40))
for fila, col, digito in [(3, 5, 0), (14, 22, 1)]:
    lienzo[fila:fila+8, col:col+8] = d.images[np.where(d.target == digito)[0][0]]

print('lienzo:', lienzo.shape, '| dos digitos puestos')
print('el clasificador acierta:', round(float(modelo.score(d.data, d.target)), 4))
lienzo: (30, 40) | dos digitos puestos
el clasificador acierta: 1.0

Un clasificador perfecto. Ahora lo paso por toda la imagen, ventana por ventana:

cajas = []
for f in range(lienzo.shape[0] - 7):
    for c in range(lienzo.shape[1] - 7):
        parche = lienzo[f:f+8, c:c+8]
        if parche.sum() == 0:
            continue
        p = modelo.predict_proba(parche.reshape(1, -1))[0]
        if p.max() > 0.9:
            cajas.append((float(p.max()), int(p.argmax()), f, c))

print('ventanas miradas:', (lienzo.shape[0] - 7) * (lienzo.shape[1] - 7))
print('cajas con confianza > 0.9:', len(cajas))
ventanas miradas: 759
cajas con confianza > 0.9: 161

161 cajas para dos dígitos. Ese es el primer problema, y tiene arreglo.

IoU: cuánto se pisan dos cajas

def iou(a, b):
    solape_f = max(0, 8 - abs(a[2] - b[2]))
    solape_c = max(0, 8 - abs(a[3] - b[3]))
    inter = solape_f * solape_c
    return inter / (64 + 64 - inter)

print('la misma caja      :', round(iou((0, 0, 3, 5), (0, 0, 3, 5)), 4))
print('corrida un pixel   :', round(iou((0, 0, 3, 5), (0, 0, 3, 6)), 4))
print('cajas separadas    :', round(iou((0, 0, 3, 5), (0, 0, 14, 22)), 4))
la misma caja      : 1.0
corrida un pixel   : 0.7778
cajas separadas    : 0.0

IoU es intersección partido unión: lo que comparten dividido entre lo que ocupan juntas. Va de 0 a 1 y es la medida que se usa en todo el oficio, tanto para juntar cajas como para calificar un detector contra las cajas que dibujó una persona.

Supresión de no máximos

def nms(cajas, umbral=0.3):
    quedan = []
    for caja in sorted(cajas, reverse=True):
        if all(iou(caja, q) < umbral for q in quedan):
            quedan.append(caja)
    return quedan

final = nms(cajas)
print('antes:', len(cajas), '-> despues:', len(final))
antes: 161 -> despues: 13

La receta entera: ordena por confianza, quédate con la mejor, tira todas las que se pisen con ella más de un 30%, repite.

De 161 a 13. Y ahora viene lo interesante.

Trece, y solo dos son de verdad

for conf, clase, f, c in sorted(final, reverse=True)[:5]:
    print('digito', clase, 'en fila', f, 'col', c, '| confianza', round(conf, 3))
digito 7 en fila 16 col 20 | confianza 1.0
digito 0 en fila 3 col 5 | confianza 1.0
digito 4 en fila 12 col 25 | confianza 1.0
digito 5 en fila 19 col 24 | confianza 1.0
digito 4 en fila 4 col 9 | confianza 1.0

La segunda es correcta: el cero está en la fila 3, columna 5, que es donde lo puse. Las otras son trozos de nada con confianza 1,000.

Y esto no se arregla subiendo el umbral, porque ya están al máximo. El problema es de fondo: mi clasificador nunca vio un recorte que no fuera un dígito centrado. Le enseñé diez opciones y le estoy preguntando por una undécima que no existe en su mundo, así que contesta la más parecida y lo hace con toda la seguridad.

Es el mismo error de la categoría desconocida del capítulo de servir un modelo de mi libro de machine learning: el modelo no sabe decir "no sé" si nunca le enseñaste a decirlo 🙃

Y por eso los detectores de verdad hacen otras tres cosas

  • Entrenan con una clase de fondo. Se le dan miles de recortes que no son nada, etiquetados como nada. Sin eso, todo lo de arriba pasa.
  • Predicen la caja, no la buscan. YOLO parte la imagen en una rejilla y cada celda predice directamente el rectángulo y la clase. Una sola pasada de la red en vez de 759 recortes, y de ahí viene el nombre: You Only Look Once.
  • Manejan varios tamaños. Mi ventana es de 8 por 8 y punto. Si el objeto es más grande, no lo veo. Los detectores miran a varias escalas a la vez.

El IoU y la supresión de no máximos que acabas de escribir siguen estando dentro de YOLO, tal cual. Eso no lo cambió nadie.

Y ahora la conversación incómoda

Entrenar un detector desde cero necesita miles de imágenes con las cajas dibujadas a mano, una por una. Ese trabajo se llama anotar y es la parte cara del proyecto, no el modelo.

Con lo que hay hoy, para la mayoría de los encargos que me llegan la respuesta correcta es una de estas dos:

  • Un modelo ya entrenado, sin tocar. Si lo que quieres contar son personas, vehículos o cajas, ya está resuelto y se usa como viene.
  • Ajuste fino sobre uno entrenado, con unos cientos de imágenes tuyas. Es el transfer learning del capítulo 18, y baja el coste de anotación en un orden de magnitud.

Entrenar desde cero se justifica cuando lo que miras no se parece a nada público. Y aun así, primero se prueban las otras dos 💛

Y en un negocio de verdad, ¿dónde entra esto?

Te lo pongo con los datos de siempre, que es lo que hace este libro.

En la tabla de ventas hay una columna unidades que alguien escribió a mano. Cuando esa columna sale mal, sale mal en la factura y sale mal en el stock, y el problema aparece un mes después cuando el inventario no cuadra 🧾

Los tres encargos que me llegan con imágenes son casi siempre estos:

  • Contar. Cuántas cajas hay en el pallet, cuántas unidades entraron al almacén. Es detección: te interesa cuántas y dónde, no la forma. Y la métrica del negocio es si el conteo cuadra con lo que dice el albarán.
  • Leer. El número de una factura, la placa de un camión, el código de un producto. Detección para encontrar el recuadro, y después un clasificador de caracteres dentro. Literalmente lo que acabas de escribir, pero con la ventana bien entrenada.
  • Medir. Qué porcentaje de la pieza tiene óxido, cuánto del lote está maduro. Eso es segmentación, porque la respuesta es un área y una caja no la da.

Y una advertencia que doy siempre antes de cotizar: la foto es el problema, no el modelo. Si las tomas las hace una persona con el celular, cada una viene con otra luz, otro ángulo y otra distancia, y un detector entrenado con fotos bonitas se cae el primer día.

Antes de hablar de arquitecturas, la pregunta es quién saca la foto, con qué y desde dónde. Si esa parte no está resuelta, el proyecto no está listo, por mucho presupuesto que haya 💛

Lo que te llevas

  • Clasificar da un número, detectar da cajas, segmentar da una etiqueta por píxel.
  • Segmentar con umbral fijo depende de la iluminación y por eso no aguanta.
  • Pasar un clasificador por ventanas da cientos de cajas repetidas.
  • IoU mide cuánto se pisan dos cajas; NMS se queda con la mejor de cada montón.
  • Un clasificador sin clase de fondo dice que sí a todo, con confianza máxima.
  • YOLO predice las cajas en una pasada, y lleva IoU y NMS dentro.
  • Lo caro es anotar, no entrenar. Empieza por un modelo ya hecho.

Comprueba que lo tienes

Pasas tu clasificador de dígitos por una imagen grande con dos dígitos y te devuelve 13 detecciones, once de ellas con confianza 1,000 sobre trozos vacíos. ¿Qué le falta al clasificador?

  • Nunca vio un recorte que no fuera un dígito centrado, así que no tiene forma de decir "aquí no hay nada"
  • Le falta entrenamiento, con más épocas acertaría
  • El umbral de confianza está muy bajo
  • Hay que usar una red convolucional en vez de una regresión

Ejercicios

1. El umbral del NMS decide cuántas te quedan

Muévelo y mira el efecto.

for u in (0.1, 0.3, 0.5, 0.9):
    print('umbral', u, '->', len(nms(cajas, u)), 'cajas')
umbral 0.1 -> 5 cajas
umbral 0.3 -> 13 cajas
umbral 0.5 -> 32 cajas
umbral 0.9 -> 161 cajas

Con 0,1 te quedas con cinco y te puedes comer objetos que de verdad estaban juntos. Con 0,9 casi no filtras nada. Y con 0,9 salen las 161 de partida, o sea que no filtró ni una. El 0,3 o el 0,5 son lo habitual, y no porque sean mágicos: es lo que se ha visto funcionar. Si tus objetos se tocan mucho, ese número hay que revisarlo.

2. El modelo no sabe decir "aquí no hay nada"

Pregúntale por un trozo completamente vacío.

vacio = np.zeros((8, 8))
p = modelo.predict_proba(vacio.reshape(1, -1))[0]
print('contesta el digito', int(p.argmax()), 'con confianza', round(float(p.max()), 4))
print('las probabilidades suman', round(float(p.sum()), 4))
contesta el digito 8 con confianza 0.1105
las probabilidades suman 1.0

Contesta un 8. Y la línea de abajo explica por qué no puede hacer otra cosa: las probabilidades tienen que sumar uno, así que siempre reparte entre las diez clases que conoce. No existe la opción "ninguna".

Acá la confianza sí es baja, 0,11, así que el umbral la filtraría. El problema son los recortes a medias, que se parecen a algo de verdad y salen con 1,000.

3. Comprueba la detección que sí acertó

Mide el IoU contra donde pusiste el dígito.

verdad = (0, 0, 3, 5)
aciertos = [(round(iou(caja, verdad), 3), caja[1], caja[2], caja[3])
            for caja in final if iou(caja, verdad) > 0.5]
print('cajas que se pisan mas del 50% con el cero real:')
for i, clase, f, c in aciertos:
    print('  IoU', i, '| dijo que era un', clase, '| fila', f, 'col', c)
cajas que se pisan mas del 50% con el cero real:
  IoU 1.0 | dijo que era un 0 | fila 3 col 5

IoU 1,0 y la clase correcta. Así se califica un detector: por cada caja que una persona dibujó, se busca la predicha que más se pise con ella, y se pide un IoU mínimo, normalmente 0,5. De ahí sale la métrica que vas a ver en todos los papers, que se llama mAP.

4. Cuánto cuesta la ventana deslizante

La cuenta que explica por qué se inventó YOLO.

for alto, ancho in ((30, 40), (480, 640), (1080, 1920)):
    ventanas = (alto - 7) * (ancho - 7)
    print('imagen', alto, 'x', ancho, '->', ventanas, 'ventanas, y eso a UNA escala')
imagen 30 x 40 -> 759 ventanas, y eso a UNA escala
imagen 480 x 640 -> 299409 ventanas, y eso a UNA escala
imagen 1080 x 1920 -> 2052649 ventanas, y eso a UNA escala

Dos millones de pasadas del clasificador para una imagen de teléfono, y eso mirando un solo tamaño de objeto. Con cinco escalas son diez millones. YOLO hace una. Ahí está toda la diferencia, y no es una mejora de porcentajes: es lo que hace posible detectar en vídeo.

5. La máscara traducida a negocio

Segmentar sirve cuando lo que importa es cuánto, no cuántos.

for i in range(3):
    m = (d.images[i] > 5)
    print('digito', d.target[i], '| pixeles de tinta', int(m.sum()),
          '| proporcion', round(float(m.mean()), 3))
digito 0 | pixeles de tinta 24 | proporcion 0.375
digito 1 | pixeles de tinta 23 | proporcion 0.359
digito 2 | pixeles de tinta 25 | proporcion 0.391

Esa proporción es la respuesta a "qué porcentaje de la pieza está oxidado" o "cuánto del terreno es cultivo". Una caja no te lo puede decir: solo dice dónde mirar. Cuando el encargo tenga la palabra área, porcentaje o superficie, es segmentación y no detección.

6. El recorte que no tiene el tamaño que espera

El error de la primera tarde con imágenes.

parche = lienzo[3:10, 5:12]
print('el recorte mide', parche.shape)
modelo.predict(parche.reshape(1, -1))
ValueError: X has 49 features, but LogisticRegression is expecting 64 features as input.

Corté 7 por 7 en vez de 8 por 8 y el modelo cuenta 49 en vez de 64. Este error sale el primer día y sale siempre, porque en imágenes todo son índices y un +1 de más se cuela solo.

La costumbre que lo evita es la misma de todo este libro: imprimir .shape antes de pasarle nada a un modelo. Y fíjate en que el error habla de features y no de píxeles, porque para el modelo una imagen ya dejó de ser una imagen en cuanto la aplastaste 🧷

Practica este capítulo 📓

Todo el código de arriba en un cuaderno que corre de principio a fin, y los ejercicios con una celda vacía para que los hagas tú. Se abre en Google Colab de un clic y no hay que instalar nada. Donde veas %%revisa, escribe tu respuesta y el cuaderno te dice si te salió.

¿Prefieres trabajar en tu máquina? Bájate el cuaderno de práctica o el de soluciones. Todos están también en github.com/soymissyera/MissYeraEjercicios.

¿Le sirve a alguien que conoces?

Pásale el libro. Es gratis, está entero y no pide registro 🐣

Instagram y TikTok no dejan compartir enlaces desde la web: esos dos copian la URL para que la pegues en tu historia.

¿Tienes alguna duda o consulta?