Minha Primeira Experiência com Visão Computacional

Não há nada como encontrar problemas reais para resolver. Muitas vezes, os melhores projetos surgem do cotidiano, quando surge a necessidade de otimizar uma tarefa repetitiva. Foi exatamente isso que aconteceu comigo.

O Problema

Em meados de 2019, um colega precisava, para sua tese de doutorado, colher grãos de café e separá-los de acordo com seus diferentes estágios de maturação. Para agilizar o trabalho, ele reunia cerca de oito pessoas. Colocávamos os grãos sobre uma mesa e, manualmente, fazíamos a separação. Apesar de funcionar, o processo era lento e consumindo um tempo que podíamos dedicar a outras atividades.

Foi nesse cenário que comecei a refletir: não haveria uma maneira de automatizar essa tarefa? Ou pelo menos agilizar parte do processo? Esse pensamento coincidiu com o período em que eu estava explorando um curso de visão computacional na Udemy. Meu interesse havia sido despertado após assistir a uma palestra em um evento de Python, onde um empreendedor contou como usava visão computacional obter o peso de bovinos em sua startup.

Naquela época não consegui tempo útil para entregar a solução ao meu colega. Mas visitando hoje meus arquivos encontrei os códigos que à época fazia os primeiros testes com macarrões. Fiz alguns ajustes e compartilho abaixo o que você pode usar como um primeiro tutorial em visão computacional.

Análise de Visão Computacional para Contagem de Frutos de Café (Algoritmo Watershed)

O código apresentado utiliza uma sequência de técnicas de visão computacional, com destaque para o algoritmo Watershed, para segmentar e contar os frutos de café em uma imagem. O Watershed é particularmente útil para separar objetos que estão tocando ou sobrepostos.

Explicação Detalhada das Etapas Operacionais

Importação dos Pacotes

Começamos nosso código pela importação dos pacotes necessários, particularmente o OpenCV e o NumPy. O OpenCV é uma biblioteca amplamente utilizada em Visão Computacional, que fornece funções para processamento e análise de imagens. Já o NumPy é uma biblioteca de operações numéricas e matriciais, essencial para manipular as imagens (tratadas como arrays) e criar estruturas como os kernels usados nas operações morfológicas.

# Importação dos pacotes
import cv2   # Biblioteca principal de Visão Computacional
import numpy as np  # Operações matriciais e cálculo numérico
  

Caso ainda não tenha esses pacotes instalados, abra o terminal e digite:

pip install opencv-python numpy  

Pré-processamento (Carregamento e Conversão para Tons de Cinza)

Figura 1 – Um exemplo do problema (gerei a imagem com IA generativa)

Antes de aplicar os algoritmos de segmentação, é importante converter a imagem colorida para escala de cinza. Isso simplifica os dados (que no caso são as cores), pois reduz cada pixel de três canais (RGB – Red, Green e Blue) para apenas um nível de intensidade. Assim, o processamento fica mais rápido e eficiente, sem perder as informações necessárias para destacar os frutos do café.

Código Aplicado:

# Carregar imagem
img = cv2.imread("frutos.png")
# Conversão para escala cinza
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
Figura 2 – Conversão para Escala de Cinza

A cor que vemos em uma imagem colorida é formada pela combinação de três canais: vermelho (R), verde (G) e azul (B). Cada pixel da foto é, na verdade, a soma dessas três intensidades. Quando convertemos para escala de cinza, deixamos de lado a informação de cor e mantemos apenas o nível de luminosidade de cada pixel, que varia do preto ao branco. Isso simplifica a análise, já que o algoritmo passa a trabalhar apenas com um canal em vez de três, sem perder as informações necessárias para identificar os frutos, um vez que o fundo da nossa imagem se diferencia dos frutos em termos de luminosidade (fundo mais claro e frutos mais escuros).

Binarização (Threshold): Separando Frutos e Fundo

Após a conversão para escala de cinza, o próximo passo crucial é a Binarização, que transforma a imagem em apenas dois tons: preto e branco. O objetivo é criar uma máscara onde os objetos de interesse (os frutos) fiquem com uma cor e o fundo com a outra.

Para isso, usamos a função cv2.threshold com o método Otsu.

Método Otsu (cv2.THRESH_OTSU): Este é um algoritmo inteligente que calcula automaticamente o melhor valor de limiar (threshold) para separar os pixels em dois grupos (frutos e fundo), maximizando a variação entre eles. Isso garante que a binarização seja ideal, mesmo que a iluminação da imagem original não seja perfeita. Em outra palavras, diante da variação de luminosidade entre fundo e frutos, a binarização separa em apenas dois casos, branco e preto. O método Otsu otimiza essa separação

Inversão (cv2.THRESH_BINARY_INV): Combinamos o Otsu com a opção THRESH_BINARY_INV para garantir que os frutos (o nosso objeto de interesse) fiquem com o valor branco (255) e o fundo fique com o valor preto (0). Isso é importante para as etapas morfológicas seguintes. Ou seja, o Ostu otimiza o processo de separação em duas luminosidades e o THRESH_BINARY_INV faz a inversão a luminosidade. Fazemos essa inversão pois os procedimentos seguintes tratam os valores brancos como objetos.

Código Aplicado:

# Binarização (Threshold)
_, thres = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)

Figura 3 – Binarização com Método Otsu (Máscara Inicial)

Remoção de Ruídos com Abertura Morfológica

A binarização, embora eficaz, pode introduzir pequenos ruídos (pontos brancos isolados) que não são frutos. Para limpar a imagem e obter uma representação mais precisa dos objetos, aplicamos a operação de Abertura Morfológica.

Operação de Abertura (cv2.MORPH_OPEN): Esta é uma combinação de duas operações básicas: erosão seguida de dilatação. Ela é excelente para:

  • Remover pequenos objetos brancos (ruído) no fundo preto.
  • Suavizar o contorno dos objetos sem alterar significativamente o tamanho.

Kernel e Iterações: O kernel (uma matriz 3×3 de uns) define o “tamanho” da vizinhança na qual a operação é aplicada. Usar iterations=2 significa que a operação de abertura será repetida duas vezes, garantindo uma limpeza mais eficiente.

Código Aplicado:

# Remover ruídos com abertura morfológica
kernel = np.ones((3, 3), np.uint8)
opening = cv2.morphologyEx(thres, cv2.MORPH_OPEN, kernel, iterations=2)

2. Remoção de Ruídos com Abertura Morfológica

A binarização pode deixar para trás pequenos pontos de ruído. Para limpar a imagem e obter a forma pura dos frutos, aplicamos a Abertura Morfológica. Esta operação composta é o filtro ideal para a tarefa, pois ela se resume a duas etapas aplicadas em sequência: Erosão, seguida de Dilatação.

Erosão e Dilatação:

A Erosão é a primeira a agir. Ela encolhe os objetos brancos (nossos frutos), eliminando pequenos ruídos brancos que não são grandes o suficiente para representarem um fruto. É o nosso passo de “limpeza”. Em seguida, a Dilatação expande os objetos restantes, restaurando o tamanho original dos pixels brancos que foram apenas encolhidos pela Erosão. O ruído (pontos brancos “soltos”), por ter sido totalmente eliminado, não volta.

O Elemento Estruturante (Kernel 3×3):

Para realizar estas operações, usamos um Kernel (np.ones((3, 3), np.uint8)). Pense no Kernel como uma “janela” 3×3 que desliza sobre a imagem. A forma mais simples (3×3 com todos os valores em 1) é escolhida por ser a mais eficiente e a que aplica a limpeza na vizinhança imediata de forma suave, preservando a forma dos frutos sem distorções excessivas.

Por que Duas Iterações?

Ao definir iterations=2, pedimos ao OpenCV para repetir todo o processo de Abertura (Erosão + Dilatação) duas vezes. Isso garante uma limpeza mais profunda e uma suavização ainda maior nas bordas, sendo um bom equilíbrio entre eficácia e velocidade de processamento.

Código Aplicado:

# 2. Remover ruídos com abertura morfológica
kernel = np.ones((3, 3), np.uint8)
opening = cv2.morphologyEx(thres, cv2.MORPH_OPEN, kernel, iterations=2)
Figura 4 – Máscara após Abertura Morfológica (Remoção de Ruído)

Desafio:

Se a Abertura é Erosão seguida de Dilatação (limpa ruído de fundo), saiba que existe o Fechamento Morfológico, que inverte a ordem: Dilatação seguida de Erosão. Te desafio a pensar: Em qual cenário de Visão Computacional o Fechamento Morfológico seria mais útil do que a Abertura? (Dica: Pense em “buracos” dentro dos objetos).

Dilatação: Definindo o “Fundo Certo” (Sure Background)

O algoritmo Watershed, que aplicaremos em breve, precisa de três regiões bem definidas para funcionar: o que é certeza de ser objeto, o que é certeza de ser fundo, e a área “desconhecida” (a borda).

Nesta etapa, focamos em criar a região de “Fundo Certo” (Sure Background). Após a abertura morfológica (que removeu os ruídos), usamos a operação de Dilatação.

Dilatação (cv2.dilate): Esta operação “expande” os pixels brancos (nosso fundo, já que a imagem foi invertida na binarização) para preencher buracos e garantir que as áreas mais externas do fundo sejam sólidas. Usamos iterations=3 para uma expansão significativa.

Por que o Fundo Certo? Ao expandir o fundo, criamos uma área segura que não toca os objetos (frutos). Isso garante que o algoritmo Watershed comece a segmentação a partir de um ponto inquestionável do fundo, evitando que as “bacias hidrográficas vazem” para áreas indesejadas.

Código Aplicado:

# Dilatação → região de fundo certa
sure_bg = cv2.dilate(opening, kernel, iterations=3)

Transformada de Distância: Definindo os “Objetos Certos” (Sure Foreground)

Se a etapa anterior definiu o “Fundo Certo”, agora precisamos encontrar a região que é “Objeto Certo” (Sure Foreground), ou seja, a área central dos frutos, onde temos certeza absoluta de que não há fundo ou bordas adjacentes.

Para isso, usamos a Transformada de Distância Euclidiana (cv2.distanceTransform).

Transformada de Distância: Pense nisso como jogar um balde de água sobre a máscara dos frutos. Cada pixel dentro do objeto recebe um valor que representa a sua distância em relação ao pixel mais próximo do fundo (preto). O resultado é uma imagem em escala de cinza onde o centro de cada fruto brilha mais forte.

Figura 5 – Transformada de Distância Euclidiana (Centros mais claros)

Binarização com Limiar (Threshold) da Distância: Para transformar esse mapa de brilho em uma máscara binária de “Objeto Certo”, aplicamos um limiar. Mantemos apenas os pixels que estão muito distantes da borda (o topo da “montanha” de distância). A fórmula 0.4 * dist_transform.max() garante que apenas os centros puros dos frutos sejam mantidos.

Código Aplicado:

# Distância euclidiana → região de objetos certos
dist_transform = cv2.distanceTransform(opening, cv2.DIST_L2, 5)
_, sure_fg = cv2.threshold(dist_transform, 0.4 * dist_transform.max(), 255, 0)
Figura 6 – Região de Objetos Certos (Sure Foreground)

Região Desconhecida: Encontrando as Fronteiras

O algoritmo Watershed opera como se estivesse inundando um terreno: as “nascentes” são os objetos certos, e a água se espalha até encontrar o “oceano” (o fundo certo). A área entre esses dois pontos é a nossa Região Desconhecida.

Esta região é calculada subtraindo a área de “Objeto Certo” (sure_fg) da área de “Fundo Certo” (sure_bg).

Subtração de Máscaras (cv2.subtract): Matematicamente, o que fazemos é:

Região Desconhecida = Fundo CertoObjeto Certo

O resultado é uma máscara que contém apenas as bordas e as linhas finas entre os frutos que estão se tocando. É essa região que o algoritmo Watershed irá “dividir” para segmentar corretamente os objetos.

Observação: Antes da subtração, garantimos que a máscara de objetos (sure_fg) esteja no formato de 8 bits (np.uint8), que é o padrão esperado pelo OpenCV para esta operação.

Código Aplicado:

# Região desconhecida
sure_fg = np.uint8(sure_fg)
unknown = cv2.subtract(sure_bg, sure_fg)
Figura 7 – Região Desconhecida (Fronteiras a serem resolvidas)

Marcadores: Preparando o Mapa para o Watershed

O algoritmo Watershed, em sua essência, requer um “mapa” de marcadores (markers) para saber de onde começar a “inundação” (segmentação). Cada objeto que queremos segmentar deve ser marcado com um número inteiro positivo único (1, 2, 3, etc.), e a região desconhecida deve ser marcada como zero.

Componentes Conectados (cv2.connectedComponents):

Primeiro, usamos a máscara de “Objeto Certo” (sure_fg) para identificar cada fruto separado. A função cv2.connectedComponents atribui um rótulo (0, 1, 2, …) a cada grupo de pixels brancos conectados. O rótulo 0 é reservado para o fundo.

Ajuste dos Marcadores:

  1. Incrementamos todos os rótulos em 1: markers = markers + 1. Isso move o fundo (que era 0) para 1, liberando o zero para a região desconhecida.
  2. Atribuímos o rótulo 0 a todos os pixels da Região Desconhecida (unknown): markers[unknown == 255] = 0.

Neste ponto, temos o mapa perfeito: cada fruto certo tem um rótulo único (2, 3, 4, …), o fundo certo tem o rótulo 1, e a fronteira a ser resolvida é marcada com 0.

Código Aplicado:

# Marcadores
_, markers = cv2.connectedComponents(sure_fg)
markers = markers + 1
markers[unknown == 255] = 0

Aplicar Watershed: Segmentação Final

Finalmente, aplicamos o algoritmo Watershed (Bacias Hidrográficas). Ele usa os Marcadores que preparamos na etapa anterior para resolver as fronteiras na Região Desconhecida.

Como Funciona: O algoritmo trata a imagem como um mapa topográfico onde o brilho do pixel representa a elevação. Ele “inunda” a imagem a partir dos nossos marcadores de “Objeto Certo” e do “Fundo Certo”. Quando duas “águas” de marcadores diferentes se encontram, ele constrói uma parede de separação.

Resultado: A função cv2.watershed modifica o mapa de markers. Os pixels de cada objeto segmentado mantêm seu rótulo original, e as fronteiras que separam esses objetos (as “paredes”) recebem o valor **-1**.

Visualização: Para vermos as separações, criamos uma cópia da imagem original (img_ws) e pintamos de **vermelho** ([0, 0, 255] no formato BGR) todos os pixels onde markers == -1. Estas são as fronteiras que o Watershed criou, segmentando os frutos que estavam se tocando.

Código Aplicado:

# Aplicar Watershed
markers = cv2.watershed(img, markers)
img_ws = img.copy()
img_ws[markers == -1] = [0, 0, 255]  # bordas em vermelho

Contagem e Visualização: Finalizando a Detecção

Com os frutos perfeitamente segmentados pelo Watershed, o último passo é quantificar os objetos e fornecer um feedback visual na imagem final.

Contornos (cv2.findContours):

Usamos a máscara de “Objeto Certo” (sure_fg) – que foi o ponto de partida dos marcadores – para encontrar os contornos externos (cv2.RETR_EXTERNAL) de cada fruto. Isso nos dá as formas de cada objeto isolado.

Filtragem e Contagem:

Iteramos sobre cada contorno encontrado, aplicando um filtro simples baseado na área (cv2.contourArea) para ignorar quaisquer pequenos ruídos remanescentes. Se a área for maior que 200 pixels, consideramos um fruto válido:

  • O contador é incrementado.
  • Calculamos o Bounding Box (cv2.boundingRect), que é o retângulo mínimo que envolve o contorno.
  • Desenhamos esse retângulo em **verde** na imagem final (img_ws).

Por fim, usamos cv2.putText para adicionar o número total de frutos detectados no canto da imagem, completando o resultado da nossa análise.

Código Aplicado:

# Contagem de objetos
contornos, _ = cv2.findContours(sure_fg, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
contador = 0
for c in contornos:
    area = cv2.contourArea(c)
    if area > 200:  # filtro simples
        contador += 1
        x, y, w, h = cv2.boundingRect(c)
        cv2.rectangle(img_ws, (x, y), (x+w, y+h), (0, 255, 0), 2)

cv2.putText(img_ws, f"{contador} frutos detectados", (20, 40),
            cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 255), 2)
Figura 8 – Resultado Final: Segmentação Watershed e Contagem

Visualização do Pipeline

Para o leitor entender a progressão do algoritmo, o código termina com a montagem de um painel de visualização (um “grid”) que mostra as principais etapas do processamento lado a lado.

Função resize: Para garantir que todas as imagens (que têm resoluções diferentes) se encaixem bem na tela, definimos uma função simples que redimensiona a largura mantendo a proporção original. Isso é crucial para uma visualização limpa.

Empilhamento (np.hstack e np.vstack): Usamos as funções do NumPy para empilhar as imagens horizontalmente (np.hstack) para formar linhas e, em seguida, empilhar essas linhas verticalmente (np.vstack) para criar a grade final.

Conversão de Cores: Imagens em escala de cinza e binárias (como o thres, sure_fg e o mapa de distância) são convertidas para 3 canais BGR (cv2.cvtColor(..., cv2.COLOR_GRAY2BGR)) antes de serem empilhadas, garantindo que o np.hstack consiga combiná-las com a imagem colorida original.

O resultado é a imagem completa que mostra a transformação de pixels até a segmentação final do Watershed.

Código Aplicado:

# Mostrar pipeline
def resize(img, w=350):
    return cv2.resize(img, (w, int(img.shape[0] * w / img.shape[1])))

linha1 = np.hstack((resize(img), cv2.cvtColor(resize(gray), cv2.COLOR_GRAY2BGR)))
linha2 = np.hstack((cv2.cvtColor(resize(thres), cv2.COLOR_GRAY2BGR),
                    cv2.cvtColor(resize(sure_fg), cv2.COLOR_GRAY2BGR)))
linha3 = np.hstack((cv2.cvtColor(resize(dist_transform.astype(np.uint8)), cv2.COLOR_GRAY2BGR),
                    resize(img_ws)))

grid = np.vstack((linha1, linha2, linha3))

cv2.imshow("Pipeline com Watershed", grid)
cv2.waitKey(0)
cv2.destroyAllWindows()

Conclusão

O algoritmo Watershed, combinado com operações morfológicas, provou ser uma ferramenta robusta para a segmentação de objetos em contato. Ao definir claramente as regiões de objeto certo, fundo certo e a fronteira desconhecida, conseguimos segmentar e contar com precisão os frutos de café.

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *