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

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.
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=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 ballsCompáralo con el conteo a mano
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)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()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:
report(pocket_v=60)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:
report(v_min=10, pocket_v=60)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

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:
report(v_min=0)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.
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