Por que funciona: Unidade 1.3: A imagem como dado
Achar todas as bolas numa mesa de sinuca com OpenCV
O segundo projeto de OpenCV na Prática: achar as bolas numa mesa de sinuca pela única cor que elas não têm, a do pano, e avaliar o resultado bola a bola contra uma contagem à mão em três fotos. 29 de 41 achadas com 2 alarmes falsos; 9 das 12 que faltaram estão encostadas em outra bola.
Tradução assistida por máquina do original em inglês. Números, código e referências foram conferidos com ele; o texto não foi revisado por um falante nativo. Ler o original. Os labs interativos e o código estão em inglês.
Poucas linhas de OpenCV acham a maioria das bolas numa mesa de sinuca procurando tudo o que não tem a cor do pano, e este projeto conta quantas elas acham e quais deixam escapar.
A animação roda o pipeline um passo de cada vez numa foto e depois mostra o resultado final nas três fotos do módulo, com as bolas achadas circuladas em verde, as perdidas em vermelho e os alarmes falsos em amarelo.

pool.webp, depois o resultado em cada foto. Fotos “Billiards table 2.JPG”, “Billiards table 1.JPG” e “Billiards Table.JPG” de MarkBuckawicki, CC0 [1].- Ler as cores em HSV. Converter a foto para que a cor (matiz) de cada pixel seja um número e o brilho outro; o pano tem uma única matiz, na luz e na sombra (lição 1).
- Mascarar o pano. Marcar todo pixel cuja matiz, saturação e brilho caem dentro da faixa do pano [2] (lição 2).
- Achar a mesa. A maior região de pano, esticada por cima das caçapas e recuada das tabelas, é a mesa; a sala fora dela é ignorada (lição 4).
- Limpar o que não é pano. Dentro da mesa, tudo o que não é pano é candidato; encolher e voltar a crescer as áreas brancas remove pontinhos e preenche buracos [2] (lição 3).
- Manter os blobs redondos e com reflexo. Traçar o contorno de cada candidato e mantê-lo se for redondo o bastante para ser uma bola e tiver um reflexo de luz, que uma caçapa não tem (lições 4 e 5).
Onde você já viu isso: o Analyze Particles do ImageJ, que conta células ou grãos por tamanho e circularidade; o filtro de chroma key do OBS e dos editores de vídeo; as selecionadoras ópticas que descartam grãos e frutas pela cor.
TL;DR. Achar bolas pela cor do pano funciona para bolas separadas e falha para bolas encostadas umas nas outras ou da mesma cor do pano, então é uma primeira ferramenta rápida cuja contagem precisa ser conferida. Em três fotos com 41 bolas ele acha 29 com 2 alarmes falsos; das 12 que perde, 9 estão encostadas em outra bola, 2 são bolas verdes no pano verde e 1 se perde na própria sombra.
As fotos e a contagem à mão
O módulo trabalha com três fotos da mesma mesa, tiradas na mesma noite de três posições e colocadas em domínio público pelo autor [1]. Cada bola apoiada no pano foi localizada à mão em recortes ampliados, como um centro e um raio no arquivo publicado de 960 pixels de largura. Uma bola em cima da tabela do fundo em pool-wide está fora do pano e ficou de fora.
| Arquivo | Bolas no pano | O que dificulta |
|---|---|---|
pool.webp | 15 | uma bola verde ao lado de uma preta, uma bola listrada de verde, duas bolas amarelas encostadas junto à tabela |
pool-close.webp | 12 | duas bolas perto da lente com sombras longas, dois pares encostados |
pool-wide.webp | 14 | bolas pequenas, um grupo de três, um par junto à tabela, uma bola verde |
Uma bola conta como achada quando o centro de um blob mantido cai dentro do contorno dela. Cada bola e cada blob só podem ser pareados uma vez, os pares mais próximos primeiro, então um blob entre duas bolas encostadas conta como um alarme falso e duas perdas.
O pipeline inteiro, rodado nas três 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)
O que cada etapa da correção ganha
O pipeline foi construído em três etapas, cada uma somando uma mudança à anterior (lição 5). Achadas do total, alarmes falsos entre parênteses:
| Etapa | pool | pool-close | pool-wide | Total |
|---|---|---|---|---|
| primeira tentativa (piso de brilho 40) | 10 de 15 (1) | 8 de 12 (1) | 9 de 14 (1) | 27 de 41 (3) |
| + descartar blobs sem reflexo | 10 de 15 (1) | 8 de 12 (0) | 8 de 14 (0) | 26 de 41 (1) |
| + piso de brilho 10 | 11 de 15 (2) | 9 de 12 (0) | 9 de 14 (0) | 29 de 41 (2) |
- O teste de reflexo remove as duas caçapas. O pixel mais claro de uma caçapa chega no máximo a 45 na escala de 0–255, e o reflexo mais fraco numa bola de verdade é 144. A contagem de achadas também cai em um, porque a “bola” que ele remove em
pool-wideera a sombra da bola verde invisível. - O piso mais baixo recupera cinco bolas e perde duas. Quatro das cinco estão onde o pano é mais escuro que 40, no fundo da mesa ou ao longo da tabela, e estavam grudadas nessa faixa escura. As duas perdidas são uma bola perto da câmera, cuja sombra agora se junta a ela pela metade, e uma de um par encostado. Ele também transforma as duas bolas amarelas junto à tabela em
pool.webpnum único alarme falso. - O piso tem pouco efeito. Com pisos de 0 a 40 e o teste de reflexo ligado, o total muda em três bolas:
| Piso de brilho | 0 | 5 | 10 | 20 | 30 | 40 |
|---|---|---|---|---|---|---|
| Achadas de 41 | 28 | 28 | 29 | 28 | 27 | 26 |
| Alarmes falsos | 3 | 2 | 2 | 2 | 2 | 1 |
O 10 foi escolhido por um motivo dado na lição 1 (abaixo de cerca de 10, a matiz é ruído), e por acaso é a melhor coluna. Com três fotos, a diferença entre 28 e 29 é uma bola.
Onde falha

| Por que a bola foi perdida | Bolas |
|---|---|
| encostada em outra bola: o par ou grupo é um único blob | 9 |
| a cor do pano: bola verde no pano verde | 2 |
| uma sombra mais escura que o piso ainda se junta à bola | 1 |
| total de perdidas | 12 de 41 |
Os alarmes falsos são um de cada tipo que a cor produz: um blob entre duas bolas encostadas e o segundo pedaço branco de uma bola listrada de verde.
O que levar daqui
- Objetos separados sobre um fundo liso de uma só cor: este pipeline basta. Todas as bolas que ele perde, menos três, estão encostadas em outra bola.
- Objetos que se encostam: a cor não consegue separá-los; 9 das 12 perdas são isso. Separe os blobs pela forma, com uma transformada de distância ou detecção de círculos, que é o que o próximo módulo faz com moedas [3]. Se os seus objetos se agrupam como as bolas depois da saída, conte com perder mais de um em cada quatro usando só a cor (12 de 41 aqui).
- Um objeto da cor do fundo: nenhum limiar de cor o acha. Mude a iluminação, o fundo ou a câmera, ou use algo que olhe para a forma.
- Meça antes de confiar. A primeira tentativa parecia boa numa foto e perdeu 14 de 41 bolas nas três. Contar levou alguns minutos por foto.
- Quando vale um modelo: este módulo não testou um detector aprendido. O número a bater é 29 de 41 com 2 alarmes falsos, e as bolas que um detector teria de acrescentar são as encostadas.
Limitações
- Os ajustes foram calibrados nas mesmas fotos em que são avaliados. Cada faixa, kernel e limiar foi escolhido olhando estas três fotos, então 29 de 41 é o melhor caso. O teste com dados não vistos é a sua própria foto: “Usar sua imagem” na primeira célula da lição 4, depois Rodar na última célula, desenha o que o pipeline mantém. Um pano de outra cor precisa da sua própria faixa de matiz.
- Três fotos, 41 bolas, uma mesa. Uma bola vale 2,4 pontos percentuais. O mesmo pano, sala, luzes e câmera aparecem nas três; um pano azul ou vermelho, ou luz do dia, pede uma nova faixa.
- Uma pessoa marcou os rótulos. Centros e raios foram marcados à mão em recortes ampliados. As duas bolas dentro do par vermelho e laranja em
pool-wideforam separadas no olho na resolução cheia de 2048 pixels. - A regra de pareamento é generosa de propósito. Qualquer blob mantido cujo centro caia dentro do contorno de uma bola conta; na primeira etapa isso deu crédito a uma sombra como se fosse a bola verde.
- “Na mesa” é uma definição. A bola em cima da tabela do fundo em
pool-wideficou de fora; contada, seria uma 42ª bola e uma 13ª perda. - As entradas são WebP com qualidade 90, reduzidas dos originais de 2048 pixels. Todos os números são calculados sobre os arquivos publicados, então o Rodar do leitor os reproduz.
As lições
- Achar a faixa HSV de um objeto no OpenCV
- Máscara de cor com cv2.inRange no OpenCV
- Erosão, dilatação, abertura e fechamento de uma máscara no OpenCV
- Encontrar, medir e filtrar contornos no OpenCV
- Onde a detecção por cor falha no OpenCV
Todas as células do módulo também estão num notebook, para rodar na sua máquina com o OpenCV que o pip instala: abra no Colab. As saídas dele batem com as da página, célula por célula.
Como estes números foram feitos
| Dados | 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); originais de 2048×1536, SHA-1 conferido com o Commons e SHA-256 registrado no registro de assets |
| Preparação | redimensionados para 960×720 com INTER_AREA, gravados como WebP qualidade 90 |
| Rótulos | 41 centros e raios de bolas marcados à mão em recortes ampliados, no quadro de 960 pixels: output/practice_m2_labels.json, publicado como /practice/m2/labels.json |
| Avaliação | um blob mantido corresponde a uma bola quando seu centro está mais perto que o raio da bola (no mínimo 8 px); pares atribuídos do mais próximo ao mais distante, um para um |
| Ajustes | matiz 75–100, saturação ≥ 80, piso de brilho 40 (primeira tentativa) ou 10 (final); mesa erodida 19×19; abertura 5×5, fechamento 7×7; área ≥ 45 px, circularidade > 0.55; teste de reflexo: valor mais claro ≥ 60 |
| Na página | Pyodide 314.0.7, OpenCV 4.11.0, NumPy 2.4.6 (a saída exibida de cada célula é gerada por npm run pycells com este build) |
| Localmente | OpenCV 5.0.0, NumPy 2.5.2, Python 3.12.9; todos os números acima batem com os da página |
| Máquina | Intel Core i7-12700H, Ubuntu 22.04.5 LTS. Nada é cronometrado; os números são determinísticos |
| Comandos | uv run practice-m2 --assets --media em fundamentals-lab com os originais em data/pool/; npm run pycells no repositório do site |
| Artefato | output/practice_m2_numbers.json |
Leitura complementar
- Gonzalez & Woods, Digital Image Processing, capítulo 7 sobre cor e capítulo 9 sobre morfologia [2], para a teoria por trás dos passos 1 a 4.
- O tutorial de watershed do OpenCV [3], que separa moedas encostadas com uma transformada de distância: a próxima ferramenta para as 9 bolas encostadas.
- Colour spaces: choosing axes you can threshold (em inglês), a lição de Fundamentos sobre por que o HSV facilita esse tipo de máscara, com um laboratório interativo de HSV.
Referências
[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