Por que funciona: Unidade 1.3: A imagem como dado

Onde a detecção por cor falha no OpenCV

Compare um pipeline de máscara de cor com uma contagem feita à mão em três fotos de mesa de sinuca, corrija as caçapas e as sombras e veja o que a cor não resolve: 27 de 41 bolas achadas no início, 29 depois das correções, 9 das 12 perdidas encostadas em outra bola.

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

Quatro falhas da detecção por cor: bolas encostadas, uma bola verde no pano verde, uma sombra, uma listra partida.
Quatro falhas da detecção por cor: bolas encostadas, uma bola verde no pano verde, uma sombra, uma listra partida.

Achar coisas pela cor funciona até que algo tenha a cor errada para a regra, e o jeito de descobrir onde isso acontece é contar, bola a bola, contra uma contagem feita à mão.

A foto da sinuca duas vezes, com anéis nas bolas: a primeira tentativa acha 10 de 15, o pipeline corrigido 11 de 15, com as duas bolas amarelas na borda esquerda ainda unidas.
Verde: uma bola achada. Vermelho: uma bola perdida. Amarelo: algo achado que não é bola. Esquerda: o pipeline das lições 1 a 4. Direita: com as duas correções desta lição. Foto de MarkBuckawicki, CC0 [4].

Onde você já viu isso: toda demo do tipo “funcionou na minha foto”; o chroma key que come uma camisa verde; uma linha de triagem que rejeita fruta boa que está na sombra.

TL;DR. O primeiro pipeline confunde caçapas escuras com bolas e perde bolas que estão sobre pano mais escuro, e uma linha corrige cada um desses erros; o que ele não consegue corrigir são bolas que se tocam e uma bola da cor do pano. Em três fotos com 41 bolas ele acha 27 no início, com 3 alarmes falsos, e 29 com 2 depois das correções; 9 das 12 bolas ainda perdidas estão encostadas em outra bola.

O problema

A lição 4 terminou com onze blobs redondos numa foto, julgados a olho. Julgar a olho favorece o método. Este módulo tem três fotos da mesma mesa de ângulos diferentes, e o centro de cada bola sobre o pano foi marcado à mão em recortes ampliados: 41 bolas no total. Uma bola conta como achada quando o centro de um blob mantido cai dentro do seu contorno, e cada bola e cada blob só podem ser pareados uma vez.

O pipeline em duas funções

Tudo das lições 1 a 4, com dois ajustes em aberto: o piso de brilho da faixa do pano, v_min, e um teste que esta lição acrescenta, pocket_v.

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=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 balls
Saída

Compare com a contagem à mão

Python · OpenCV
import json
labels = json.load(open("labels.json"))

def score(balls, truth):
    # pares mais próximos primeiro; um acerto cai dentro da 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)
Saída
Python · OpenCV
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()
Saída
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, com 3 alarmes falsos, um por foto. As imagens de depuração mostram três tipos de erro por omissão e dois tipos de alarme falso, e dois deles têm correção de uma linha.

Correção 1: uma caçapa é um buraco

Em pool-close e pool-wide uma caçapa passa por todos os testes: não é pano, fica dentro do contorno da mesa e é redonda. O que ela não tem é luz. Toda bola é brilhante e carrega um reflexo; uma caçapa não tem nenhum. Nas três fotos, o pixel mais claro de cada blob que este teste descarta fica em no máximo 45 na escala de valor de 0–255, e o reflexo mais fraco numa bola de verdade é 144, então um limiar de 60 tem folga dos dois lados:

Python · OpenCV
report(pocket_v=60)
Saída
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

Os alarmes falsos caem de 3 para 1. A contagem de achadas também cai, de 27 para 26, e isso é uma correção, não uma perda: em pool-wide a bola verde é invisível contra o pano, e o que vinha sendo contado como ela era a sua sombra, um blob escuro que caía logo dentro do seu contorno. A regra da caçapa o remove pelo mesmo motivo que remove as caçapas.

Correção 2: pano escuro ainda é pano

A lição 1 leu o pano no fundo da mesa com valor 37 e uma sombra sobre ele com 21, os dois abaixo do piso de 40 da faixa. Então a máscara chama uma faixa escura de pano de “não pano”, e uma bola em cima dessa faixa se junta a ela num blob irregular que reprova no teste de circularidade. Baixe o piso:

Python · OpenCV
report(v_min=10, pocket_v=60)
Saída
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 falsos. A mudança troca os erros de lugar mais do que os elimina: cinco bolas voltam, quatro delas no fundo escuro ou ao longo da borda escura, e duas se perdem. A bola azul perto da câmera em pool-close se perde porque a sua sombra, agora em parte pano, deixa um núcleo escuro irregular grudado nela; a bola preta em pool.webp troca de lugar com a bola laranja em que encosta, já que só uma bola de um par encostado pode ser pareada. As duas bolas amarelas na borda em pool.webp, livres do pano escuro em volta delas, agora passam como um único blob redondo cujo centro cai entre as duas: um alarme falso e duas perdidas.

O piso vai para 10 e não para 0 por um motivo que a célula Quebre mostra. O valor exato importa pouco e de forma ruidosa: qualquer piso de 0 a 20 acha 28 ou 29, o que a página do projeto lista por completo.

O que a cor não resolve

Quatro recortes da saída do pipeline: três bolas encostadas com anel vermelho, uma bola verde no pano verde com anel vermelho, uma bola azul unida à sua sombra com anel vermelho, e os dois pedaços brancos de uma bola de listra verde, um com anel verde e outro amarelo.
As quatro falhas que restam. Bolas encostadas viram um blob só (acima à esquerda). Uma bola verde tem a cor do pano (acima à direita). Um núcleo escuro de sombra ainda se junta a uma bola (abaixo à esquerda). Uma bola de listra verde se parte em dois pedaços brancos, um contado como a bola e outro como alarme falso (abaixo à direita). Fotos de MarkBuckawicki, CC0.

Das 12 bolas ainda perdidas, 9 encostam em outra bola, 2 são bolas verdes no pano verde e 1 é a bola azul perto da câmera, cuja sombra é escura o bastante para ficar abaixo até do piso rebaixado. Nenhuma faixa de cor separa duas bolas vermelhas que se tocam, e nenhuma faixa de cor separa uma bola verde do pano verde sem também cortar o pano. Isso pede outro tipo de ferramenta: forma (a transformada de distância, ou a detecção de círculos) para objetos que se tocam [3], que o próximo módulo usa em moedas, ou um detector treinado.

Quebre

Baixe o piso até 0 e tire a regra da caçapa:

Quebre
report(v_min=0)
Saída
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 para 15 alarmes falsos. Com valor 0, a faixa do pano aceita qualquer pixel quase preto cujo matiz por acaso caia entre 75 e 100, e numa sala escura depois da compressão muitos caem. O contorno da mesa, o fecho convexo da maior região de “pano”, cresce para dentro da sala, e tudo o que está nela vira objeto.

Sua vez

A página do projeto informa o total com vários pisos. Rode o pipeline com v_min=20 e pocket_v=60 nas três fotos e guarde o total de bolas achadas em found20.

Sua vez
found20 = 0
# seu laço aqui
print(found20)

Por que funciona

A regra da caçapa funciona porque uma caçapa é um buraco que devolve quase nenhuma luz, enquanto uma bola é uma esfera brilhante, então em algum ponto dela a fonte de luz se reflete quase branca. A correção das sombras funciona porque o HSV guarda o brilho num número próprio (lição 1) [1], então uma versão mais escura do pano tem o mesmo matiz e um valor menor, e um único limite decide se ela conta. Nenhuma das duas muda o que o pipeline é: um teste da cor de cada pixel, depois da forma de cada blob. Objetos que dividem uma cor e se tocam precisam da forma do próprio blob para serem separados, o problema que o método watershed resolve para moedas encostadas com uma transformada de distância [3] [2]. A cor também não tem resposta para uma bola verde: para isso, veja how close is your colour (em inglês), sobre o pouco que separa duas cores próximas depois que uma câmera as registra.

Erros comuns

  • Julgar a olho numa foto só. A primeira tentativa parece boa em pool.webp. Contar em três fotos achou 14 perdidas e 3 alarmes falsos.
  • Contar blobs em vez de pareá-los. Um blob que fica entre duas bolas é um alarme falso e duas perdidas, não “uma bola achada”. Pareie cada blob com no máximo uma bola.
  • Baixar um limiar até 0. O matiz não significa nada num pixel preto. A célula Quebre mostra o que o 0 faz.
  • Ajustar nas fotos que você reporta. Cada ajuste aqui foi escolhido olhando para estas três fotos, então 29 de 41 é o melhor caso. Teste uma foto sua com “Usar sua imagem” na lição 4.

Referências

[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