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.
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.
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.

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].- 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).
- 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).
- 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).
- 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).
- 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.
| Archivo | Bolas en el paño | Qué lo hace difícil |
|---|---|---|
pool.webp | 15 | una bola verde junto a una negra, una bola rayada verde, dos bolas amarillas que se tocan junto a la banda |
pool-close.webp | 12 | dos bolas cerca del lente con sombras largas, dos pares que se tocan |
pool-wide.webp | 14 | bolas 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
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)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 ballsimport 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")pool 11 of 15, 2 false pool-close 9 of 12, 0 false pool-wide 9 of 14, 0 false
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)
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:
| Etapa | pool | pool-close | pool-wide | Total |
|---|---|---|---|---|
| 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 reflejo | 10 de 15 (1) | 8 de 12 (0) | 8 de 14 (0) | 26 de 41 (1) |
| + piso de brillo 10 | 11 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-wideera 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.webpen 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 brillo | 0 | 5 | 10 | 20 | 30 | 40 |
|---|---|---|---|---|---|---|
| Encontradas de 41 | 28 | 28 | 29 | 28 | 27 | 26 |
| Falsas alarmas | 3 | 2 | 2 | 2 | 2 | 1 |
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

| Por qué se perdió la bola | Bolas |
|---|---|
| toca otra bola: el par o el grupo es un solo blob | 9 |
| el color del paño: bola verde sobre paño verde | 2 |
| una sombra más oscura que el piso sigue unida a la bola | 1 |
| total de perdidas | 12 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-widese 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-widese 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
- Encontrar el rango HSV de un objeto en OpenCV
- Crear una máscara de color con cv2.inRange en OpenCV
- Erosión, dilatación, apertura y cierre de una máscara en OpenCV
- Encontrar, medir y filtrar contornos en OpenCV
- 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
| Datos | Wikimedia 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ón | redimensionadas a 960×720 con INTER_AREA, guardadas como WebP calidad 90 |
| Etiquetas | 41 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ón | un 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 |
| Ajustes | tono 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ágina | Pyodide 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) |
| Localmente | OpenCV 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áquina | Intel Core i7-12700H, Ubuntu 22.04.5 LTS. No se mide ningún tiempo; los números son deterministas |
| Comandos | uv run practice-m2 --assets --media en fundamentals-lab con los originales en data/pool/; npm run pycells en el repositorio del sitio |
| Artefacto | output/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