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.