Por qué funciona: Unidad 1.3: La imagen como datos

Dónde falla la detección por color en OpenCV

Compara un pipeline de máscara de color con un conteo hecho a mano en tres fotos de mesa de billar, corrige las troneras y las sombras y ve lo que el color no puede arreglar: 27 de 41 bolas encontradas al principio, 29 tras las correcciones, 9 de las 12 perdidas tocando otra bola.

Luis Condados · · 7 min de lectura Principiante

Traducción asistida por máquina del original en inglés. Los números, el código y las referencias se comprobaron contra él; el texto no fue revisado por un hablante nativo. Leer el original. Los labs interactivos y el código están en inglés.

Cuatro fallas de la detección por color: bolas que se tocan, una bola verde sobre paño verde, una sombra, una franja partida.
Cuatro fallas de la detección por color: bolas que se tocan, una bola verde sobre paño verde, una sombra, una franja partida.

Encontrar cosas por su color funciona hasta que algo tiene el color equivocado para la regla, y la forma de averiguar dónde pasa eso es contar, bola por bola, contra un conteo hecho a mano.

La foto del billar dos veces, con anillos en las bolas: el primer intento encuentra 10 de 15, el pipeline corregido 11 de 15, con las dos bolas amarillas de la banda izquierda todavía unidas.
Verde: una bola encontrada. Rojo: una bola perdida. Amarillo: algo encontrado que no es una bola. Izquierda: el pipeline de las lecciones 1 a 4. Derecha: con las dos correcciones de esta lección. Foto de MarkBuckawicki, CC0 [4].

Dónde ya lo has visto: toda demo del tipo “funcionó con mi foto”; el chroma key que se come una camisa verde; una línea de clasificación que rechaza fruta buena que está a la sombra.

TL;DR. El primer pipeline confunde troneras oscuras con bolas y pierde bolas que están sobre paño más oscuro, y una línea corrige cada cosa; lo que no puede corregir son las bolas que se tocan y una bola del color del paño. En tres fotos con 41 bolas encuentra 27 al principio, con 3 falsas alarmas, y 29 con 2 tras las correcciones; 9 de las 12 bolas que siguen perdidas tocan otra bola.

El problema

La lección 4 terminó con once blobs redondos en una foto, juzgados a ojo. Juzgar a ojo favorece al método. Este módulo tiene tres fotos de la misma mesa desde ángulos distintos, y el centro de cada bola sobre el paño se marcó a mano en recortes ampliados: 41 bolas en total. Una bola cuenta como encontrada cuando el centro de un blob conservado cae dentro de su contorno, y cada bola y cada blob solo se pueden emparejar una vez.

El pipeline en dos funciones

Todo lo de las lecciones 1 a 4, con dos ajustes abiertos: el piso de brillo del rango del paño, v_min, y una prueba que añade esta lección, pocket_v.

Python · OpenCV
import cv2
import numpy as np

def objects_on_table(hsv, v_min):
    cloth = cv2.inRange(hsv, (75, 80, v_min), (100, 255, 255))
    cs, _ = cv2.findContours(cloth, cv2.RETR_EXTERNAL,
                             cv2.CHAIN_APPROX_SIMPLE)
    hull = cv2.convexHull(max(cs, key=cv2.contourArea))
    table = cv2.fillPoly(np.zeros_like(cloth), [hull], 255)
    table = cv2.erode(table, np.ones((19, 19), np.uint8))
    obj = cv2.bitwise_and(cv2.bitwise_not(cloth), table)
    k5, k7 = np.ones((5, 5), np.uint8), np.ones((7, 7), np.uint8)
    obj = cv2.morphologyEx(obj, cv2.MORPH_OPEN, k5)
    return cv2.morphologyEx(obj, cv2.MORPH_CLOSE, k7)
Salida
Python · OpenCV
def find_balls(img, v_min=40, pocket_v=0):
    hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
    obj = objects_on_table(hsv, v_min)
    cs, _ = cv2.findContours(obj, cv2.RETR_EXTERNAL,
                             cv2.CHAIN_APPROX_SIMPLE)
    balls = []
    for c in cs:
        a, p = cv2.contourArea(c), cv2.arcLength(c, True)
        blob = cv2.drawContours(np.zeros_like(obj), [c], -1, 255, -1)
        brightest = hsv[..., 2][blob > 0].max()
        round_ = a >= 45 and 4 * np.pi * a / p**2 > 0.55
        if round_ and brightest >= pocket_v:
            balls.append(cv2.minEnclosingCircle(c)[0])
    return balls
Salida

Compáralo con el conteo a mano

Python · OpenCV
import json
labels = json.load(open("labels.json"))

def score(balls, truth):
    # primero los pares más cercanos; un acierto cae dentro de la bola
    pairs = sorted((np.hypot(bx - x, by - y), i, j)
                   for i, (x, y, r) in enumerate(truth)
                   for j, (bx, by) in enumerate(balls))
    hit_t, hit_b = set(), set()
    for d, i, j in pairs:
        if i not in hit_t and j not in hit_b and d < max(truth[i][2], 8):
            hit_t.add(i)
            hit_b.add(j)
    return len(hit_t), len(balls) - len(hit_b)
Salida
Python · OpenCV
photos = ["pool", "pool-close", "pool-wide"]
imgs = {n: cv2.imread(f"{n}.webp") for n in photos}

def report(**settings):
    total = np.zeros(2, int)
    for n in photos:
        found, false = score(find_balls(imgs[n], **settings), labels[n])
        total += (found, false)
        print(f"{n:11} {found:2} of {len(labels[n])}, {false} false")
    print(f"{'total':11} {total[0]} of 41, {total[1]} false")

report()
Salida
pool        10 of 15, 1 false
pool-close   8 of 12, 1 false
pool-wide    9 of 14, 1 false
total       27 of 41, 3 false

27 de 41, con 3 falsas alarmas, una por foto. Las imágenes de depuración muestran tres tipos de bola perdida y dos tipos de falsa alarma, y dos de ellos se arreglan con una línea.

Corrección 1: una tronera es un agujero

En pool-close y pool-wide una tronera pasa todas las pruebas: no es paño, está dentro del contorno de la mesa y es redonda. Lo que no tiene es luz. Toda bola es brillante y lleva un reflejo; una tronera no tiene ninguno. En las tres fotos, el píxel más claro de cada blob que esta prueba descarta está como mucho en 45 en la escala de valor de 0–255, y el reflejo más tenue en una bola de verdad es 144, así que un umbral de 60 tiene margen por los dos lados:

Python · OpenCV
report(pocket_v=60)
Salida
pool        10 of 15, 1 false
pool-close   8 of 12, 0 false
pool-wide    8 of 14, 0 false
total       26 of 41, 1 false

Las falsas alarmas bajan de 3 a 1. El conteo de encontradas también baja, de 27 a 26, y eso es una corrección, no una pérdida: en pool-wide la bola verde es invisible contra el paño, y lo que se contaba como ella era su sombra, un blob oscuro que caía justo dentro de su contorno. La regla de la tronera lo elimina por la misma razón por la que elimina las troneras.

Corrección 2: el paño oscuro sigue siendo paño

La lección 1 leyó el paño del fondo de la mesa con valor 37 y una sombra sobre él con 21, los dos por debajo del piso de 40 del rango. Así que la máscara llama “no paño” a una tira oscura de paño, y una bola sobre esa tira se une a ella en un blob irregular que no pasa la prueba de redondez. Baja el piso:

Python · OpenCV
report(v_min=10, pocket_v=60)
Salida
pool        11 of 15, 2 false
pool-close   9 of 12, 0 false
pool-wide    9 of 14, 0 false
total       29 of 41, 2 false

29 de 41, 2 falsas. El cambio mueve los errores de sitio más de lo que los quita: vuelven cinco bolas, cuatro de ellas en el fondo oscuro o a lo largo de la banda oscura, y se pierden dos. La bola azul cerca de la cámara en pool-close se pierde porque su sombra, ahora en parte paño, le deja pegado un núcleo oscuro irregular; la bola negra en pool.webp cambia de lugar con la bola naranja que toca, ya que solo una bola de un par que se toca se puede emparejar. Las dos bolas amarillas de la banda en pool.webp, libres del paño oscuro que las rodeaba, ahora pasan como un solo blob redondo cuyo centro cae entre las dos: una falsa alarma y dos perdidas.

El piso baja a 10 y no a 0 por una razón que muestra la celda Rómpelo. El valor exacto importa poco y de forma ruidosa: cualquier piso de 0 a 20 encuentra 28 o 29, lo que la página del proyecto lista completo.

Lo que el color no puede arreglar

Cuatro recortes de la salida del pipeline: tres bolas que se tocan con anillo rojo, una bola verde sobre paño verde con anillo rojo, una bola azul unida a su sombra con anillo rojo, y los dos pedazos blancos de una bola de franja verde, uno con anillo verde y otro amarillo.
Las cuatro fallas que quedan. Las bolas que se tocan se vuelven un solo blob (arriba a la izquierda). Una bola verde es del color del paño (arriba a la derecha). Un núcleo oscuro de sombra todavía se une a una bola (abajo a la izquierda). Una bola de franja verde se parte en dos pedazos blancos, uno contado como la bola y otro como falsa alarma (abajo a la derecha). Fotos de MarkBuckawicki, CC0.

De las 12 bolas que siguen perdidas, 9 tocan otra bola, 2 son bolas verdes sobre paño verde y 1 es la bola azul cerca de la cámara, cuya sombra es lo bastante oscura para quedar por debajo incluso del piso rebajado. Ningún rango de color separa dos bolas rojas que se tocan, y ningún rango de color separa una bola verde del paño verde sin cortar también el paño. Esto necesita otro tipo de herramienta: la forma (la transformada de distancia, o la detección de círculos) para objetos que se tocan [3], que el próximo módulo usa con monedas, o un detector entrenado.

Rómpelo

Baja el piso hasta 0 y quita la regla de la tronera:

Rómpelo
report(v_min=0)
Salida
pool        11 of 15, 2 false
pool-close   8 of 12, 15 false
pool-wide    9 of 14, 1 false
total       28 of 41, 18 false

pool-close salta a 15 falsas alarmas. Con valor 0, el rango del paño acepta cualquier píxel casi negro cuyo tono caiga por casualidad entre 75 y 100, y en un cuarto oscuro después de la compresión muchos caen ahí. El contorno de la mesa, la envolvente convexa de la mayor región de “paño”, crece hacia el cuarto, y todo lo que hay en él se vuelve un objeto.

Tu turno

La página del proyecto da el total con varios pisos. Ejecuta el pipeline con v_min=20 y pocket_v=60 en las tres fotos y guarda el total de bolas encontradas en found20.

Tu turno
found20 = 0
# tu bucle aquí
print(found20)

Por qué funciona

La regla de la tronera funciona porque una tronera es un agujero que casi no devuelve luz, mientras que una bola es una esfera brillante, así que en algún punto de ella la fuente de luz se refleja casi blanca. La corrección de las sombras funciona porque HSV guarda el brillo en su propio número (lección 1) [1], así que una versión más oscura del paño tiene el mismo tono y un valor más bajo, y un solo límite decide si cuenta. Ninguna de las dos cambia lo que es el pipeline: una prueba del color de cada píxel y luego de la forma de cada blob. Los objetos que comparten un color y se tocan necesitan la forma del propio blob para separarse, el problema que el método watershed resuelve para monedas que se tocan con una transformada de distancia [3] [2]. El color tampoco tiene respuesta para una bola verde: para eso, mira how close is your colour (en inglés), sobre lo poco que separa dos colores cercanos una vez que una cámara los registra.

Errores comunes

  • Juzgar a ojo en una sola foto. El primer intento se ve bien en pool.webp. Contar en tres fotos encontró 14 perdidas y 3 falsas alarmas.
  • Contar blobs en vez de emparejarlos. Un blob que queda entre dos bolas es una falsa alarma y dos perdidas, no “una bola encontrada”. Empareja cada blob con como mucho una bola.
  • Bajar un umbral hasta 0. El tono no significa nada en un píxel negro. La celda Rómpelo muestra lo que hace el 0.
  • Ajustar con las fotos que reportas. Cada ajuste de aquí se eligió mirando estas tres fotos, así que 29 de 41 es el mejor caso. Prueba con una foto tuya con “Usar tu imagen” en la lección 4.

Referencias

[1] OpenCV. Changing Colorspaces (Python tutorial, doc/py_tutorials/py_imgproc/py_colorspaces/py_colorspaces.markdown). OpenCV 4.11.0, commit 31b0eee. github.com/opencv/opencv

[2] Gonzalez, R. C., & Woods, R. E. (2018). Digital Image Processing (4th ed.), section 10.7 “Segmentation Using Morphological Watersheds”, p. 849. Pearson.

[3] OpenCV. Image Segmentation with Watershed Algorithm (Python tutorial, doc/py_tutorials/py_imgproc/py_watershed/py_watershed.markdown). OpenCV 4.11.0, commit 31b0eee. github.com/opencv/opencv

[4] MarkBuckawicki. Billiards table 1.JPG, Billiards table 2.JPG and Billiards Table.JPG (27 Feb 2014). Wikimedia Commons, CC0 1.0. commons.wikimedia.org