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

Encontrar todas las bolas de una mesa de billar con OpenCV

El segundo proyecto de OpenCV en la Práctica: encontrar las bolas de una mesa de billar por el único color que no tienen, el del paño, y evaluar el resultado bola por bola contra un conteo a mano en tres fotos. 29 de 41 encontradas con 2 falsas alarmas; 9 de las 12 que se escapan están tocando otra bola.

Luis Condados · · 9 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.

El pipeline terminado sobre la foto del billar: bolas encontradas en verde, perdidas en rojo, falsas alarmas en amarillo.
El pipeline terminado sobre la foto del billar: bolas encontradas en verde, perdidas en rojo, falsas alarmas en amarillo.

Unas pocas líneas de OpenCV encuentran la mayoría de las bolas de una mesa de billar buscando todo lo que no tiene el color del paño, y este proyecto cuenta cuántas encuentran y cuáles se les escapan.

La animación ejecuta el pipeline paso a paso sobre una foto y luego muestra el resultado final en las tres fotos del módulo, con las bolas encontradas rodeadas en verde, las perdidas en rojo y las falsas alarmas en amarillo.

Una foto de una mesa de billar que se convierte, paso a paso, en una máscara del paño, el contorno de la mesa, una máscara de todo lo que no es paño y blobs redondos contorneados, seguida del resultado en tres fotos: 11 de 15, 9 de 12 y 9 de 14 bolas encontradas.
Pasos 1 a 5 sobre pool.webp, luego el resultado en cada foto. Fotos “Billiards table 2.JPG”, “Billiards table 1.JPG” y “Billiards Table.JPG” de MarkBuckawicki, CC0 [1].
1 · leer colores en HSVcv2.cvtColor2 · enmascarar el pañocv2.inRange3 · encontrar la mesafindContours · convexHull4 · limpiar el restocv2.morphologyEx5 · redondos y con reflejocontourArea · arcLengthcomparar con 41 bolas marcadas a mano, ajustar el rango
  1. Leer los colores en HSV. Convertir la foto para que el color (tono) de cada píxel sea un número y su brillo otro; el paño tiene un solo tono, con luz y en sombra (lección 1).
  2. Enmascarar el paño. Marcar cada píxel cuyo tono, saturación y brillo caen dentro del rango del paño [2] (lección 2).
  3. Encontrar la mesa. La región de paño más grande, estirada sobre las troneras y recortada hacia adentro de las bandas, es la mesa; la sala que queda fuera se ignora (lección 4).
  4. Limpiar lo que no es paño. Dentro de la mesa, todo lo que no es paño es un candidato; encoger y volver a hacer crecer las zonas blancas elimina puntitos y rellena huecos [2] (lección 3).
  5. Quedarse con los blobs redondos y con reflejo. Trazar el contorno de cada candidato y conservarlo si es lo bastante redondo para ser una bola y tiene un reflejo de luz, que una tronera no tiene (lecciones 4 y 5).

Dónde ya lo has visto: Analyze Particles en ImageJ, que cuenta células o granos por tamaño y circularidad; el filtro de chroma key de OBS y de los editores de video; las clasificadoras ópticas que descartan granos y frutas por su color.

TL;DR. Encontrar bolas por el color del paño funciona con bolas separadas y falla con bolas que se tocan entre sí o que tienen el color del paño, así que es una primera herramienta rápida cuyo conteo hay que revisar. En tres fotos con 41 bolas encuentra 29 con 2 falsas alarmas; de las 12 que pierde, 9 están tocando otra bola, 2 son bolas verdes sobre paño verde y 1 se pierde en su propia sombra.

Las fotos y el conteo a mano

El módulo trabaja con tres fotos de la misma mesa, tomadas la misma noche desde tres posiciones y puestas en el dominio público por su autor [1]. Cada bola apoyada en el paño se ubicó a mano sobre recortes ampliados, como un centro y un radio en el archivo publicado de 960 píxeles de ancho. Una bola encima de la banda del fondo en pool-wide está fuera del paño y se dejó fuera.

ArchivoBolas en el pañoQué lo hace difícil
pool.webp15una bola verde junto a una negra, una bola rayada verde, dos bolas amarillas que se tocan junto a la banda
pool-close.webp12dos bolas cerca del lente con sombras largas, dos pares que se tocan
pool-wide.webp14bolas pequeñas, un grupo de tres, un par junto a la banda, una bola verde

Una bola cuenta como encontrada cuando el centro de un blob conservado cae dentro de su contorno. Cada bola y cada blob se pueden emparejar una sola vez, primero los pares más cercanos, así que un blob entre dos bolas que se tocan cuenta como una falsa alarma y dos pérdidas.

El pipeline completo, ejecutado en las tres fotos

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=10, pocket_v=60):
    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
Python · OpenCV
import json
labels = json.load(open("labels.json"))

def score(balls, truth):
    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)

for n in ["pool", "pool-close", "pool-wide"]:
    found, false = score(find_balls(cv2.imread(f"{n}.webp")), labels[n])
    print(f"{n:11} {found:2} of {len(labels[n])}, {false} false")
Salida
pool        11 of 15, 2 false
pool-close   9 of 12, 0 false
pool-wide    9 of 14, 0 false
Python · OpenCV
out = cv2.imread("pool.webp")
for x, y in find_balls(out):
    cv2.circle(out, (int(x), int(y)), 24, (0, 255, 0), 3)
cv2.imshow("pool.webp: what the pipeline kept", out)
Salida
pool.webp: what the pipeline kept
pool.webp: what the pipeline kept

Qué gana cada etapa de la corrección

El pipeline se construyó en tres etapas, cada una sumando un cambio a la anterior (lección 5). Encontradas del total, falsas alarmas entre paréntesis:

Etapapoolpool-closepool-wideTotal
primer intento (piso de brillo 40)10 de 15 (1)8 de 12 (1)9 de 14 (1)27 de 41 (3)
+ descartar blobs sin reflejo10 de 15 (1)8 de 12 (0)8 de 14 (0)26 de 41 (1)
+ piso de brillo 1011 de 15 (2)9 de 12 (0)9 de 14 (0)29 de 41 (2)
  • La prueba del reflejo elimina las dos troneras. El píxel más claro de una tronera llega como mucho a 45 en la escala de 0–255, y el reflejo más tenue en una bola real es 144. El conteo de encontradas también baja en una, porque la “bola” que elimina en pool-wide era la sombra de la bola verde invisible.
  • El piso más bajo recupera cinco bolas y pierde dos. Cuatro de las cinco están donde el paño es más oscuro que 40, al fondo de la mesa o a lo largo de la banda, y estaban unidas a esa franja oscura. Las dos perdidas son una bola cerca de la cámara cuya sombra ahora se le une a medias y una de un par que se toca. También convierte las dos bolas amarillas junto a la banda en pool.webp en una sola falsa alarma.
  • El piso tiene poco efecto. Con pisos de 0 a 40 y la prueba del reflejo activada, el total se mueve en tres bolas:
Piso de brillo0510203040
Encontradas de 41282829282726
Falsas alarmas322221

El 10 se eligió por un motivo explicado en la lección 1 (por debajo de alrededor de 10, el tono es ruido), y resulta ser la mejor columna. Con tres fotos, la diferencia entre 28 y 29 es una bola.

Dónde falla

Cuatro recortes de la salida del pipeline: tres bolas que se tocan rodeadas en rojo, una bola verde sobre paño verde rodeada en rojo, una bola azul unida a su sombra rodeada en rojo, y los dos trozos blancos de una bola rayada verde, uno rodeado en verde y otro en amarillo.
Las bolas que se tocan se vuelven un solo blob (arriba a la izquierda). Una bola verde tiene el color del paño (arriba a la derecha). El núcleo oscuro de una sombra se une a una bola cerca de la cámara (abajo a la izquierda). Una bola rayada verde se parte en dos trozos blancos, uno contado como la bola y otro como falsa alarma (abajo a la derecha).
Por qué se perdió la bolaBolas
toca otra bola: el par o el grupo es un solo blob9
el color del paño: bola verde sobre paño verde2
una sombra más oscura que el piso sigue unida a la bola1
total de perdidas12 de 41

Las falsas alarmas son una de cada tipo que produce el color: un blob entre dos bolas que se tocan y el segundo trozo blanco de una bola rayada verde.

Qué sacar de esto

  • Objetos separados sobre un fondo liso de un solo color: este pipeline alcanza. Todas las bolas que pierde, salvo tres, están tocando otra bola.
  • Objetos que se tocan: el color no puede separarlos; 9 de las 12 pérdidas son eso. Separa los blobs por su forma, con una transformada de distancia o detección de círculos, que es lo que hace el siguiente módulo con monedas [3]. Si tus objetos se agrupan como las bolas después de la salida, cuenta con perder más de uno de cada cuatro usando solo el color (12 de 41 aquí).
  • Un objeto del color del fondo: ningún umbral de color lo encuentra. Cambia la iluminación, el fondo o la cámara, o usa algo que mire la forma.
  • Mide antes de confiar. El primer intento se veía bien en una foto y perdió 14 de 41 bolas en las tres. Contar llevó unos minutos por foto.
  • Cuándo vale la pena un modelo: este módulo no probó un detector aprendido. El número a superar es 29 de 41 con 2 falsas alarmas, y las bolas que un detector tendría que sumar son las que se tocan.

Limitaciones

  • Los ajustes se calibraron en las mismas fotos con que se evalúan. Cada rango, kernel y umbral se eligió mirando estas tres fotos, así que 29 de 41 es el mejor caso. La prueba con datos no vistos es tu propia foto: “Usar tu imagen” en la primera celda de la lección 4, y luego Ejecutar en su última celda, dibuja lo que el pipeline conserva. Un paño de otro color necesita su propio rango de tono.
  • Tres fotos, 41 bolas, una mesa. Una bola equivale a 2,4 puntos porcentuales. El mismo paño, la misma sala, las mismas luces y la misma cámara aparecen en las tres; un paño azul o rojo, o la luz del día, necesitan un rango nuevo.
  • Una sola persona puso las etiquetas. Centros y radios se marcaron a mano sobre recortes ampliados. Las dos bolas dentro del par rojo y naranja en pool-wide se separaron a ojo con la resolución completa de 2048 píxeles.
  • La regla de emparejamiento es generosa a propósito. Cuenta cualquier blob conservado cuyo centro caiga dentro del contorno de una bola; en la primera etapa eso le dio a una sombra el crédito de la bola verde.
  • “En la mesa” es una definición. La bola encima de la banda del fondo en pool-wide se dejó fuera; contada, sería una 42.ª bola y una 13.ª pérdida.
  • Las entradas son WebP con calidad 90, reducidas desde los originales de 2048 píxeles. Todos los números se calculan sobre los archivos publicados, así que el Ejecutar del lector los reproduce.

Las lecciones

  1. Encontrar el rango HSV de un objeto en OpenCV
  2. Crear una máscara de color con cv2.inRange en OpenCV
  3. Erosión, dilatación, apertura y cierre de una máscara en OpenCV
  4. Encontrar, medir y filtrar contornos en OpenCV
  5. Dónde falla la detección por color en OpenCV

Todas las celdas del módulo también están en un notebook, para ejecutarlo en tu computadora con el OpenCV que instala pip: ábrelo en Colab. Sus salidas coinciden con las de la página, celda por celda.

Cómo se obtuvieron estos números
DatosWikimedia Commons, de MarkBuckawicki, CC0 1.0 [1]: “Billiards table 2.JPG” (pool.webp), “Billiards table 1.JPG” (pool-close.webp), “Billiards Table.JPG” (pool-wide.webp); originales de 2048×1536, SHA-1 verificado con Commons y SHA-256 anotado en el registro de assets
Preparaciónredimensionadas a 960×720 con INTER_AREA, guardadas como WebP calidad 90
Etiquetas41 centros y radios de bolas marcados a mano sobre recortes ampliados, en el cuadro de 960 píxeles: output/practice_m2_labels.json, publicado como /practice/m2/labels.json
Evaluaciónun blob conservado corresponde a una bola cuando su centro está más cerca que el radio de la bola (al menos 8 px); emparejamientos asignados del más cercano al más lejano, uno a uno
Ajustestono 75–100, saturación ≥ 80, piso de brillo 40 (primer intento) o 10 (final); mesa erosionada 19×19; apertura 5×5, cierre 7×7; área ≥ 45 px, redondez > 0.55; prueba del reflejo: valor más claro ≥ 60
En la páginaPyodide 314.0.7, OpenCV 4.11.0, NumPy 2.4.6 (la salida mostrada de cada celda la genera npm run pycells con este build)
LocalmenteOpenCV 5.0.0, NumPy 2.5.2, Python 3.12.9; todos los números de arriba coinciden con los de la página
MáquinaIntel Core i7-12700H, Ubuntu 22.04.5 LTS. No se mide ningún tiempo; los números son deterministas
Comandosuv run practice-m2 --assets --media en fundamentals-lab con los originales en data/pool/; npm run pycells en el repositorio del sitio
Artefactooutput/practice_m2_numbers.json

Para seguir leyendo

  • Gonzalez & Woods, Digital Image Processing, capítulo 7 sobre color y capítulo 9 sobre morfología [2], para la teoría detrás de los pasos 1 a 4.
  • El tutorial de watershed de OpenCV [3], que separa monedas que se tocan con una transformada de distancia: la siguiente herramienta para las 9 bolas que se tocan.
  • Colour spaces: choosing axes you can threshold (en inglés), la lección de Fundamentos sobre por qué HSV facilita este tipo de máscara, con un laboratorio interactivo de HSV.

Referencias

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

[2] Gonzalez, R. C., & Woods, R. E. (2018). Digital Image Processing (4th ed.), section 7.7 “Using Color in Image Segmentation”, p. 575; section 9.2 “Erosion and Dilation”, p. 696; section 9.3 “Opening and Closing”, p. 702. 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