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.

Luis Condados · · 8 min de leitura Iniciante

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.

O pipeline pronto na foto da sinuca: bolas achadas em verde, perdidas em vermelho, alarmes falsos em amarelo.
O pipeline pronto na foto da sinuca: bolas achadas em verde, perdidas em vermelho, alarmes falsos em amarelo.

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.

Uma foto de mesa de sinuca virando, passo a passo, uma máscara do pano, o contorno da mesa, uma máscara de tudo o que não é pano e blobs redondos contornados, seguida do resultado em três fotos: 11 de 15, 9 de 12 e 9 de 14 bolas achadas.
Passos 1 a 5 em 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].
1 · ler as cores em HSVcv2.cvtColor2 · mascarar o panocv2.inRange3 · achar a mesafindContours · convexHull4 · limpar o restocv2.morphologyEx5 · redondos e com reflexocontourArea · arcLengthcomparar com 41 bolas marcadas à mão, ajustar a faixa
  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).
  2. Mascarar o pano. Marcar todo pixel cuja matiz, saturação e brilho caem dentro da faixa do pano [2] (lição 2).
  3. 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).
  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).
  5. 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.

ArquivoBolas no panoO que dificulta
pool.webp15uma bola verde ao lado de uma preta, uma bola listrada de verde, duas bolas amarelas encostadas junto à tabela
pool-close.webp12duas bolas perto da lente com sombras longas, dois pares encostados
pool-wide.webp14bolas 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

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)
Saída
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
Saída
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")
Saída
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)
Saída
pool.webp: what the pipeline kept
pool.webp: what the pipeline kept

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:

Etapapoolpool-closepool-wideTotal
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 reflexo10 de 15 (1)8 de 12 (0)8 de 14 (0)26 de 41 (1)
+ piso de brilho 1011 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-wide era 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.webp num ú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 brilho0510203040
Achadas de 41282829282726
Alarmes falsos322221

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

Quatro recortes da saída do pipeline: três bolas encostadas circuladas em vermelho, uma bola verde no pano verde circulada em vermelho, uma bola azul que se juntou à própria sombra circulada em vermelho, e os dois pedaços brancos de uma bola listrada de verde, um circulado em verde e outro em amarelo.
Bolas encostadas viram um único blob (em cima, à esquerda). Uma bola verde tem a cor do pano (em cima, à direita). O núcleo escuro de uma sombra se junta a uma bola perto da câmera (embaixo, à esquerda). Uma bola listrada de verde se parte em dois pedaços brancos, um contado como a bola e outro como alarme falso (embaixo, à direita).
Por que a bola foi perdidaBolas
encostada em outra bola: o par ou grupo é um único blob9
a cor do pano: bola verde no pano verde2
uma sombra mais escura que o piso ainda se junta à bola1
total de perdidas12 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-wide foram 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-wide ficou 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

  1. Achar a faixa HSV de um objeto no OpenCV
  2. Máscara de cor com cv2.inRange no OpenCV
  3. Erosão, dilatação, abertura e fechamento de uma máscara no OpenCV
  4. Encontrar, medir e filtrar contornos no OpenCV
  5. 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
DadosWikimedia 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çãoredimensionados para 960×720 com INTER_AREA, gravados como WebP qualidade 90
Rótulos41 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çãoum 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
Ajustesmatiz 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áginaPyodide 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)
LocalmenteOpenCV 5.0.0, NumPy 2.5.2, Python 3.12.9; todos os números acima batem com os da página
MáquinaIntel Core i7-12700H, Ubuntu 22.04.5 LTS. Nada é cronometrado; os números são determinísticos
Comandosuv run practice-m2 --assets --media em fundamentals-lab com os originais em data/pool/; npm run pycells no repositório do site
Artefatooutput/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