4  Morfologia Matemática e Segmentação de Imagens

Este capítulo apresenta dois temas fundamentais do Processamento Digital de Imagens (PDI): a morfologia matemática e a segmentação de imagens. A morfologia matemática fornece um arcabouço teórico baseado na teoria dos conjuntos para analisar, refinar e quantificar a forma de objetos em imagens binárias e em tons de cinza, por meio de operadores fundamentais como erosão e dilatação. A segmentação, por sua vez, tem como objetivo particionar a imagem em regiões de interesse, separando objetos do fundo e produzindo representações adequadas para análise e interpretação.

O capítulo inicia com a limiarização, uma das técnicas mais importantes de segmentação, introduzindo o método automático de Otsu e revisitando a análise de histogramas por meio da variância interclasses, apresentada no Capítulo 1. Em seguida, são estudados os principais operadores da morfologia matemática, incluindo erosão, dilatação, abertura, fechamento e reconstrução morfológica, que permitem refinar máscaras binárias e preservar estruturas relevantes dos objetos. Por fim, são apresentadas técnicas de segmentação baseada em regiões, como a rotulação de componentes conexos, a transformada de distância e o algoritmo watershed baseado em marcadores, culminando na extração de descritores geométricos e na geração de bounding boxes compatíveis com sistemas modernos de detecção de objetos.

4.1 Objetivos

Ao final deste capítulo, você será capaz de:

  • Aplicar limiarização: Compreender o critério automático de Otsu por maximização da variância interclasses (\(\sigma_B^2\)) e selecionar estratégias adequadas de pré-processamento para facilitar a segmentação;
  • Dominar morfologia binária: Compreender e aplicar erosão (\(A\ominus B\)) e dilatação (\(A\oplus B\)) como operadores fundamentais, derivando abertura (\(A\circ B\)), fechamento (\(A\bullet B\)) e operações baseadas em reconstrução morfológica, como mm.clohole e mm.edgeoff;
  • Aplicar morfologia em tons de cinza: Utilizar gradiente morfológico e filtros top-hat para realce e análise de estruturas locais;
  • Rotular componentes conexos: Identificar e separar regiões conectadas em imagens binárias por meio de algoritmos de rotulação;
  • Aplicar transformada de distância: Interpretar e calcular distâncias ao fundo utilizando abordagens morfológicas e métricas geométricas;
  • Segmentar por regiões: Construir pipelines de segmentação baseados em marcadores utilizando Transformada de Distância e o algoritmo watershed;
  • Extrair descritores geométricos: Calcular propriedades como área, perímetro, centróide, circularidade e bounding boxes por meio de cv2.connectedComponentsWithStats e cv2.findContours;
  • Relacionar PDI e visão computacional: Compreender como descritores extraídos por segmentação podem ser convertidos para formatos utilizados por detectores modernos, como YOLO.
import os, importlib, urllib.request
import numpy as np
import matplotlib.pyplot as plt

BASE_URL = "https://raw.githubusercontent.com/fzampirolli/pdi-vc/master/morph"
for f in ["morph.py"]:
    if not os.path.exists(f):
        urllib.request.urlretrieve(f"{BASE_URL}/{f}", f)

import morph
importlib.reload(morph)
from morph import mm

version = getattr(morph, "__version__", "local_file")
print(f"✅ Ambiente pronto. Módulo 'morph' carregado (versão: {version}).")
✅ Ambiente pronto. Módulo 'morph' carregado (versão: 1.1.5).

4.2 Limiarização

A limiarização (thresholding) é uma das formas mais simples e eficientes de segmentação de imagens. Seu objetivo é classificar cada pixel em duas classes de intensidade, normalmente associadas a objeto e fundo:

\[ g(x,y) = \begin{cases} 255, & \text{se } f(x,y) > T \\ 0, & \text{caso contrário} \end{cases} \tag{4.1}\]

em que \(f(x,y)\) representa a intensidade do pixel na imagem original e \(g(x,y)\) a imagem binária resultante.

A escolha do limiar \(T\) é importante para a qualidade da segmentação. O método de Otsu determina automaticamente o limiar ótimo ao maximizar a variância interclasses \(\sigma_B^2\) e definida por:

\[ \sigma_B^2(T) = w_0(T)\,w_1(T)\, \bigl[\mu_0(T)-\mu_1(T)\bigr]^2 \tag{4.2}\]

em que:

  • \(w_0(T)\) e \(w_1(T)\) são as probabilidades acumuladas das classes fundo e objeto;
  • \(\mu_0(T)\) e \(\mu_1(T)\) são as médias de intensidade dessas classes;
  • \(\sigma_B^2(T)\) representa a variância interclasses para um dado limiar \(T\).

O método funciona melhor quando o histograma apresenta dois grupos de intensidades relativamente separados. Para isso, o algoritmo avalia todos os limiares possíveis da imagem — tipicamente no intervalo \([0,255]\) para imagens de 8 bits — e seleciona o valor que maximiza a variância entre classes, denotada por \(\sigma_B^2\):

\[ T^* = \arg\max_{T \in [0,255]} \sigma_B^2(T) \]

NotaOtsu assume histogramas bimodais

O método de Otsu produz melhores resultados quando o histograma apresenta dois picos bem definidos (bimodalidade), correspondentes ao fundo e ao objeto. Quanto maior a separação entre esses picos e mais pronunciado o máximo de \(\sigma_B^2\), mais confiável tende a ser o limiar obtido.

Em imagens com iluminação não uniforme ou múltiplas regiões de intensidade, técnicas de limiarização adaptativa — nas quais o limiar é calculado localmente — costumam produzir segmentações mais robustas.

O índice \(B\) em \(\sigma_B^2\) significa between classes (entre classes). Assim, \(\sigma_B^2\) representa a variância entre as classes (between-class variance).

4.2.1 Imagem de Moedas

A imagem utilizada para praticar a segmentação é uma fotografia de coleção de moedas de diferentes países e épocas (Figura 4.1). Crédito: GAZI.MD.AHAD (CC BY-SA 4.0). Ela apresenta objetos circulares com bordas bem definidas, sendo ideal para demonstrar limiarização, operadores morfológicos, transformada de distância, watershed e descritores de forma.

import os

url     = "https://upload.wikimedia.org/wikipedia/commons/2/25/GAZI.MD.AHAD_11.jpg"
caminho = "imagens/coins.jpg"

if not os.path.exists(caminho):
    os.makedirs("imagens", exist_ok=True)
    img_obj = mm.read(url, pil=True)
    mm.write(img_obj, caminho)
else:
    img_obj = mm.read(caminho, pil=True)

img_coins_color = np.array(img_obj)
img_coins_gray  = mm.gray(img_coins_color)

print(f"Dimensões [y,x,c]: {img_coins_color.shape}")
mm.show(img_coins_color, scale=30)
Dimensões [y,x,c]: (2560, 1920, 3)
Figura 4.1: Imagem com moedas de vários tipos. Crédito: GAZI.MD.AHAD (CC BY-SA 4.0).

4.2.2 Escolha do Pré-processamento para o Otsu

A qualidade do método de Otsu depende diretamente de quão bimodal é o histograma da imagem de entrada. A imagem original das moedas apresenta iluminação não uniforme e moedas escuras (cobre oxidado) com intensidades próximas às do fundo escuro, tornando o histograma pouco bimodal.

Para minimizar essas limitações, serão avaliadas duas técnicas clássicas de pré-processamento, apresentadas no capítulo anterior, aplicadas antes da etapa de limiarização. A Tabela 4.1 resume as características de cada abordagem. Essas técnicas buscam aumentar a separação entre objeto e fundo, tornando o histograma mais próximo de uma distribuição bimodal.

Tabela 4.1: Técnicas de pré-processamento avaliadas para melhorar a separação entre moedas e fundo antes da aplicação do método de Otsu.
Técnica O que faz Quando usar
CLAHE Equalização de histograma adaptativa local Baixo contraste global ou regional
Gaussiano Suavização por convolução com gaussiana Ruído de alta frequência (textura do fundo)

A função cv2.createCLAHE(clipLimit, tileGridSize) divide a imagem em blocos e aplica equalização de histograma em cada um deles, limitando a amplificação de ruído por meio do parâmetro clipLimit. Já o filtro Gaussiano (cv2.GaussianBlur) suaviza texturas finas que poderiam criar falsos picos no histograma.

Para comparar objetivamente qual pré-processamento produz a melhor entrada para o método de Otsu, serão apresentados, para cada versão da imagem, a própria imagem, o histograma com o limiar ótimo \(T^*\) destacado, a curva \(\sigma_B^2(T)\) e o resultado da binarização.

NotaCritério de comparação

A versão que apresentar o maior valor de \(\sigma_B^2\) em seu pico fornece a melhor separação entre as classes de fundo e objeto e, consequentemente, a melhor entrada para o método de Otsu (Figura 4.2).

import cv2, io

def otsu_criterio(img):
    h=mm.hist(img); p=h/h.sum();                  # histograma e probabilidades
    sigma2=np.zeros(len(p)) 
    for T in range(1,len(p)):                     # percorre limiares
        w0,w1=p[:T].sum(),p[T:].sum()             # probabilidades das classes
        if w0*w1==0: continue                     # evita divisão por zero
        mu0=(np.arange(T)*p[:T]).sum()/w0         # média fundo
        mu1=(np.arange(T,len(p))*p[T:]).sum()/w1  # média objeto
        sigma2[T]=w0*w1*(mu0-mu1)**2              # σ²B(T)
    return sigma2,np.argmax(sigma2)               # curva e T ótimo

def fig2img(fig):
    b=io.BytesIO(); fig.savefig(b,format='png',dpi=100)   # figura → buffer
    plt.close(fig); b.seek(0)
    return np.array(plt.imread(b))                        # buffer → array

def plot_curve(y,T,title,ylabel,color):
    fig,ax=plt.subplots(figsize=(4,3))
    ax.plot(y,color=color) if ylabel=="σ²B" else ax.bar(range(len(y)),y,color=color,width=1)
    ax.axvline(T,color='red',lw=2,label=f"T*={T}")       # limiar ótimo
    ax.set(xlabel="T" if ylabel=="σ²B" else "Intensidade",ylabel=ylabel)
    ax.legend(fontsize=8); plt.tight_layout()
    return fig2img(fig)

# ── Pré-processamentos ───────────────────────────────────────────────────────
clahe=cv2.createCLAHE(clipLimit=2.0,tileGridSize=(8,8))
img_clahe = clahe.apply(img_coins_gray)
img_gauss = cv2.GaussianBlur(img_clahe,(5,5),0)
imgs0=[("Original",img_coins_gray),
       ("CLAHE",img_clahe),
       ("CLAHE+Gauss",img_gauss)]

# ── Tabela comparativa ───────────────────────────────────────────────────────
print(f"{'Versão':<18}{'T*':>6}{'σ²B pico':>14}")
print("-"*40)

imgs,titles=[],[]
for nome,img in imgs0:
    sigma2,T=otsu_criterio(img)                           # calcula σ²B(T)
    print(f"{nome:<18}{T:>6}{sigma2[T]:>14.4e}")
    imgs += [
        img,                                              # imagem
        plot_curve(mm.hist(img),T,f"Hist T*={T}","Freq.","steelblue"),
        plot_curve(sigma2,T,"σ²B(T)","σ²B","darkorange"),
        mm.threshold(img)                                # Otsu final
    ]
    titles += [nome,f"Hist T*={T}","σ²B(T)",f"Otsu T*={T}"]

# ── Exibição final ───────────────────────────────────────────────────────────
mm.show(imgs,titles=titles,cols=4,figsize=(12,12),dpi=200)
Versão                T*      σ²B pico
----------------------------------------
Original             105    1.9437e+03
CLAHE                122    2.4695e+03
CLAHE+Gauss          123    2.4283e+03
Figura 4.2: Comparação dos pré-processamentos: imagem | histograma+T* | σ²B(T) | Otsu. A melhor separação bimodal indica o limiar mais confiável.

4.2.3 Resultado: CLAHE como Melhor Pré-processamento

A análise da Figura 4.2 indica que o CLAHE obteve o maior valor da variância inter-classes (\(\sigma_B^2 \approx 2{,}47 \times 10^3\)), com limiar ótimo \(T^* = 122\). Embora a combinação CLAHE+Gaussiano tenha produzido resultado muito semelhante (\(\sigma_B^2 \approx 2{,}43 \times 10^3\), \(T^* = 123\)), o critério quantitativo do método de Otsu favorece ligeiramente o uso do CLAHE isoladamente.

Em termos visuais, as imagens binarizadas obtidas com CLAHE e CLAHE+Gaussiano são praticamente equivalentes. A diferença entre as duas abordagens torna-se mais evidente na análise dos histogramas e dos valores de \(\sigma_B^2(T)\) do que na inspeção direta das segmentações resultantes. Assim, a escolha do CLAHE baseia-se principalmente na maximização da separação estatística entre as classes de fundo e objeto.

DicaInterpretação dos resultados

Observe que os pré-processamentos com CLAHE e CLAHE+Gaussiano produzem histogramas e limiares ótimos muito próximos (\(T^*=122\) e \(T^*=123\)). Consequentemente, as imagens binarizadas resultantes também são bastante semelhantes. Nesse caso, a decisão não é baseada em diferenças visuais marcantes, mas no critério objetivo do método de Otsu: o maior valor de \(\sigma_B^2\) indica a melhor separação entre as classes.

4.3 Morfologia Matemática

A morfologia matemática é uma teoria baseada em conjuntos utilizada para analisar a forma e a estrutura de objetos em imagens. Diferentemente dos filtros lineares apresentados no Capítulo 3, os operadores morfológicos são não lineares, pois se baseiam em operações de mínimo, máximo e inclusão espacial, em vez de combinações lineares de intensidades. Esses operadores atuam sobre a vizinhança de cada pixel por meio de um elemento estruturante \(\mathbb{B}\), responsável por definir a forma e o tamanho da região analisada.

Em imagens binárias e em tons de cinza com elementos planos, o elemento estruturante transladado para a posição \(x\) é definido espacialmente como:

\[ \mathbb{B}_x = \{ x + b \mid b \in \mathbb{B} \} \]

Nas regiões de borda da imagem, parte do conjunto \(\mathbb{B}_x\) pode extrapolar o domínio físico da cena (\(\mathbb{E}\)). Para garantir a consistência matemática dos operadores primitivos nessas fronteiras, assume-se teoricamente que o espaço exterior ao domínio da imagem é preenchido com o elemento neutro da operação correspondente (infinito positivo para a erosão e infinito negativo para a dilatação), impedindo que o ambiente externo corrompa as estruturas internas do objeto.

Quando o elemento estruturante associa pesos a seus elementos — isto é, \(b: \mathbb{B} \to \mathbb{Z}\) — ele é denominado função estruturante ou elemento estruturante não plano.

Desenvolvida por Matheron e Serra na década de 1960 para imagens binárias e posteriormente estendida a tons de cinza, a morfologia matemática fundamenta operadores como gradiente morfológico, top-hat, watershed e transformada de distância, todos derivados de dois primitivos: erosão e dilatação (Matheron, 1975; Serra, 1982).

4.3.1 Erosão e Dilatação

Os dois operadores primitivos são definidos de forma unificada para imagens em tons de cinza (\(f: \mathbb{E} \to \mathbb{Z}\)) e, por restrição ao domínio \(\{0,1\}\), também para imagens binárias.

4.3.1.1 Erosão

A Erosão de uma imagem \(f\) por uma função estruturante \(b: \mathbb{B} \to \mathbb{Z}\) é definida formalmente por:

\[ \varepsilon_b(f)(x) = (f \ominus b)(x) = \min_{z \in \mathbb{B}}\{\, f(x + z) - b(z) \,\}, \quad \forall\, x \in \mathbb{E} \tag{4.3}\]

Na prática, a erosão substitui a intensidade do pixel \(x\) pelo mínimo valor resultante da diferença entre a imagem e o elemento estruturante na vizinhança definida pelo domínio \(\mathbb{B}\). Valores positivos nos pesos de \(b(z)\) forçam o resultado local para baixo, “cavando” o relevo da imagem mais profundamente e intensificando a erosão.

No caso plano (onde os pesos são nulos dentro do domínio, ou seja, \(b \equiv 0\)), a expressão simplifica-se para o mínimo local puro:

\[ \varepsilon_B(f)(x) = \min\{\, f(y) : y \in \mathbb{B}_x \,\} \]

Em imagens binárias, essa operação equivale a exigir que o conjunto \(\mathbb{B}\), transladado para a coordenada \(x\), esteja completamente contido no objeto \(A\):

\[ A \ominus \mathbb{B} = \{\, z \in \mathbb{E} \mid \mathbb{B}_z \subseteq A \,\} \]

Efeito Visual: Encolhe objetos e estruturas claras, eliminando protuberâncias, picos brilhantes ou ruídos que sejam geometricamente menores que o domínio \(\mathbb{B}\).

4.3.1.2 Implementação da erosão

A versão didática mm.ero0 implementa o caso particular de erosão com elemento estruturante plano. Para cada pixel \((y,x)\), a função percorre os vizinhos espaciais permitidos por \(B\) e armazena o menor valor encontrado na imagem de entrada \(f\), reproduzindo diretamente a operação de mínimo local descrita na Equação 4.3 para \(b \equiv 0\).

Observe que os valores dos vizinhos são sempre lidos de forma estática da imagem original \(f\); a matriz de saída \(g\) é utilizada exclusivamente para registrar o mínimo acumulado da vizinhança corrente. Dessa forma, o resultado final é invariante em relação à ordem de varredura dos pixels (seja por linhas ou colunas).

A função auxiliar _viz calcula as coordenadas dos vizinhos válidos dentro dos limites físicos da imagem. Nas bordas, a inicialização do acumulador em 255 emula com exatidão o preenchimento por elemento neutro exigido pela teoria. Já a função de interface mm.ero recorre à implementação nativa e otimizada do OpenCV (cv2.erode) quando o elemento estruturante é plano, chaveando para a rotina geral mm.ero1 caso o elemento possua pesos topográficos.

O exemplo computacional a seguir ilustra a aplicação de um elemento estruturante em cruz (mm.secross()) em destaque na Figura 4.3, comparando a execução da variante didática em laço (mm.ero0) com o motor computacional do OpenCV (mm.ero).

B_cruz = mm.secross()
mm.drawImgPlt(B_cruz, scale=20)
Figura 4.3: Elemento estruturante em formato de cruz (\(B_{\text{cruz}}\)) utilizado para conectividade-4.
def _viz(f, B, y, x):
    """Gera (vy, vx, b_val) para cada vizinho válido de (y,x)."""
    H, W = f.shape
    Bh, Bw = B.shape
    oh, ow = -Bh/2 + 0.5, -Bw/2 + 0.5  # offsets fixos
    for by, bx in np.ndindex(Bh, Bw):
        vy, vx = int(y + by + oh), int(x + bx + ow)
        if 0 <= vy < H and 0 <= vx < W:
            yield vy, vx, B[by, bx]

def ero(f, Bc=np.zeros((3,3),dtype='uint8')):
    """Erosão (OpenCV ou com pesos)."""
    try:    return cv2.erode(f, Bc)
    except: return mm.ero1(f, Bc)

def ero0(f, Bc=np.ones((3,3),dtype='uint8')):
    """Erosão clássica sem pesos."""
    g = np.empty_like(f)
    for y in range(f.shape[0]):
        for x in range(f.shape[1]):
            g[y,x] = 255
            for vy,vx,bv in mm._viz(f,Bc,y,x):
                if bv and g[y,x] > f[vy,vx]: g[y,x] = f[vy,vx]
    return g

# Script de Teste e Validação
B = mm.secross()
print("Elemento estruturante B:")
print(mm.drawImage(B))

f = mm.randomImage(5,5)
print("Imagem original f:")
print(mm.drawImage(f))

print("Erosão Plana Didática (ero0):")
print(mm.drawImage(ero0(f, B)))

print("Erosão Otimizada OpenCV (ero):")
print(mm.drawImage(ero(f, B)))
Elemento estruturante B:
0 1 0 
1 1 1 
0 1 0 

Imagem original f:
8 0 3 5 3 
1 9 9 8 1 
8 2 2 5 4 
9 9 7 2 9 
6 9 7 1 9 

Erosão Plana Didática (ero0):
0 0 0 3 1 
1 0 2 1 1 
1 2 2 2 1 
6 2 2 1 2 
6 6 1 1 1 

Erosão Otimizada OpenCV (ero):
0 0 0 3 1 
1 0 2 1 1 
1 2 2 2 1 
6 2 2 1 2 
6 6 1 1 1 

A função _viz utiliza yield para gerar cada vizinho sob demanda, sem armazenar todos os resultados em memória em uma lista. No experimento abaixo, uma janela de 3000×3000 produz 9 milhões de vizinhos. A implementação baseada em lista consumiu mais de 1 GB de RAM e levou aproximadamente 46 s, enquanto a versão com yield consumiu memória desprezível e executou em 38 s. Em processamento de imagens, geradores são especialmente úteis para percorrer grandes vizinhanças de forma eficiente.

import tracemalloc

def lista(n): return [(i, i) for i in range(n)]
def gera(n):
    for i in range(n): yield i, i

for nome, f in [("LISTA", lista), ("YIELD", gera)]:
    tracemalloc.start()
    sum(x+y for x,y in f(9_000_000))
    _, pico = tracemalloc.get_traced_memory()
    tracemalloc.stop()
    print(f"{nome}: {pico/1024/1024:.1f} MB")
LISTA: 830.8 MB
YIELD: 0.0 MB

4.3.1.3 Dilatação

A Dilatação de uma imagem \(f\) por uma função estruturante \(b: \mathbb{B} \to \mathbb{Z}\) é definida formalmente por:

\[ \delta_b(f)(x) = (f \oplus b)(x) = \max_{z \in \mathbb{B}}\{\, f(x - z) + b(z) \,\}, \quad \forall\, x \in \mathbb{E} \tag{4.4}\]

Na prática, a dilatação substitui a intensidade do pixel \(x\) pelo maior valor resultante da soma entre a imagem e o elemento estruturante na vizinhança definida. O argumento de inversão espacial (\(x - z\)) indica que a dilatação avalia implicitamente o elemento transposto (refletido) \(\hat{b}\), propriedade fundamental para assegurar a dualidade matemática em relação à erosão.

No caso plano (onde os pesos são nulos dentro do domínio, ou seja, \(b \equiv 0\)), a expressão reduz-se ao máximo local puro:

\[ \delta_B(f)(x) = \max\{\, f(y) : y \in \mathbb{B}_x \,\} \]

Em imagens binárias, essa operação equivale a exigir que o conjunto refletido \(\hat{\mathbb{B}}\), transladado para a coordenada \(x\), possua interseção não vazia com o objeto \(A\):

\[ A \oplus \mathbb{B} = \{\, z \in \mathbb{E} \mid \hat{\mathbb{B}}_z \cap A \neq \varnothing \,\} \]

Efeito Visual: Expande as estruturas claras da imagem, aumentando o preenchimento de objetos, conectando componentes próximos e eliminando canais, fossas escuras ou vales que sejam geometricamente menores que o domínio \(\mathbb{B}\).

4.3.1.4 Implementação da dilatação

A versão didática mm.dil0 implementa o caso particular de dilatação com elemento estruturante plano. Para cada pixel \((y,x)\), a função percorre os vizinhos espaciais permitidos por \(B\) e armazena o maior valor encontrado na imagem de entrada \(f\), reproduzindo diretamente a operação de máximo local para \(b \equiv 0\).

Antes de iniciar a varredura espacial, o elemento estruturante sofre uma reflexão geométrica por meio da instrução np.flip(Bc) para construir explicitamente a matriz transposta \(\hat{B}\) exigida pela teoria. Em máscaras perfeitamente simétricas (como cruzes, quadrados e discos centrados na origem), essa reflexão não altera o arranjo dos pixels; contudo, para elementos assimétricos, tal etapa é estritamente necessária para garantir a equivalência com as definições formais e salvaguardar as leis de dualidade.

Assim como verificado no operador de erosão, os valores dos vizinhos são sempre lidos de forma estática a partir da matriz original \(f\), enquanto a matriz de saída \(g\) atua puramente como o registrador do máximo acumulado da vizinhança. Nas fronteiras da imagem, a inicialização do acumulador em 0 emula com exatidão o preenchimento externo por elemento neutro (\(-\infty\), ou zero em representações de 8 bits), garantindo que as bordas físicas da cena sejam dilatadas em perfeita conformidade com o padrão adotado pelo OpenCV.

O exemplo computacional abaixo ilustra a aplicação prática de um elemento em cruz (mm.secross()), validando a consistência entre a lógica em laços (mm.dil0) e o método nativo industrial (mm.dil).

def dil(f, Bc=np.zeros((3,3),dtype='uint8')):
    """Dilatação (OpenCV ou com pesos)."""
    try:    return cv2.dilate(f, Bc)
    except: return mm.dil1(f, Bc)

def dil0(f, Bc=np.zeros((3,3),dtype='uint8')):
    """Dilatação plana seguindo rigorosamente a teoria."""
    g = np.empty_like(f) 
    Bc = np.flip(Bc)     # reflexão explícita: B̂
    for y in range(f.shape[0]):
        for x in range(f.shape[1]):
            g[y,x] = 0 # Inicializa com o valor mínimo para buscar o máximo
            for vy,vx,bv in mm._viz(f,Bc,y,x):
                if bv and g[y,x] < f[vy,vx]:
                    g[y,x] = f[vy,vx]
    return g

# Script de Teste e Validação
B = mm.secross()
print("Elemento estruturante B:")
print(mm.drawImage(B))

f = mm.randomImage(5,5)
print("Imagem original f:")
print(mm.drawImage(f))

print("Dilatação Plana Didática (dil0):")
print(mm.drawImage(dil0(f, B)))

print("Dilatação Otimizada OpenCV (dil):")
print(mm.drawImage(dil(f, B)))
Elemento estruturante B:
0 1 0 
1 1 1 
0 1 0 

Imagem original f:
9 2 0 8 4 
9 3 8 6 1 
1 4 1 1 3 
2 9 4 9 6 
6 8 6 8 7 

Dilatação Plana Didática (dil0):
9 9 8 8 8 
9 9 8 8 6 
9 9 8 9 6 
9 9 9 9 9 
8 9 8 9 8 

Dilatação Otimizada OpenCV (dil):
9 9 8 8 8 
9 9 8 8 6 
9 9 8 9 6 
9 9 9 9 9 
8 9 8 9 8 
NotaNota: O Confronto dos Sinais (\(f(x+z)\) vs \(f(x-z)\))

Compare as definições formais da erosão (Equação 4.3) e da dilatação (Equação 4.4). Considere um elemento estruturante assimétrico à direita \(\mathbb{B}=\{0,1\}\) (origem e um pixel à direita) aplicado na posição \(x=10\).

  1. Na Erosão (Equação 4.3):

    \[ \min\{f(x+z)-b(z)\} \]

    • \(z=0 \Rightarrow f(10+0)=\mathbf{f(10)}\)
    • \(z=1 \Rightarrow f(10+1)=\mathbf{f(11)}\)

    O operador consulta o pixel atual (\(10\)) e o pixel à direita (\(11\)), preservando a orientação original de \(\mathbb{B}\).

  2. Na Dilatação (Equação 4.4):

    \[ \max\{f(x-z)+b(z)\} \]

    • \(z=0 \Rightarrow f(10-0)=\mathbf{f(10)}\)
    • \(z=1 \Rightarrow f(10-1)=\mathbf{f(9)}\)

    Devido ao sinal negativo (\(-z\)), avançar no elemento estruturante corresponde a recuar na imagem, fazendo com que a dilatação consulte o pixel à esquerda (\(9\)).

A função _viz, utilizada em morph.py, gera vizinhos por deslocamentos aditivos da forma \(x+z\). Por esse motivo, a implementação de mm.dil0 reflete previamente o elemento estruturante por meio de np.flip(B). Após a reflexão, a varredura baseada em \(x+z\) passa a acessar exatamente os mesmos pontos definidos pela expressão teórica \(f(x-z)\) da dilatação em Equação 4.4.

Para elementos estruturantes simétricos (como discos, quadrados e cruzes centradas), a reflexão não altera a máscara. Já para elementos assimétricos, essa etapa é indispensável para que a implementação reproduza corretamente a definição matemática da dilatação e preserve a dualidade erosão–dilatação.

NotaDualidade erosão–dilatação

Erosão e dilatação são duais pelo complemento. Isso significa que um operador pode ser completamente obtido a partir do outro, desde que se atue sobre o complemento da imagem utilizando o elemento estruturante refletido \(\hat{B}\):

\[ (A \ominus B)^c = A^c \oplus \hat{B} \quad \Longleftrightarrow \quad A \ominus B = (A^c \oplus \hat{B})^c \]

De forma análoga, a dilatação também pode ser obtida a partir da erosão:

\[ (A \oplus B)^c = A^c \ominus \hat{B} \quad \Longleftrightarrow \quad A \oplus B = (A^c \ominus \hat{B})^c \]

Em termos práticos, a erosão de um objeto pode ser obtida pela dilatação de seu complemento, seguida da complementação do resultado (e vice-versa). Na implementação do pacote morph.py, as versões didáticas mm.ero0 e mm.dil0 tornam explícita essa estrutura por meio de laços (loops), enquanto mm.ero e mm.dil delegam as operações ao OpenCV visando maior eficiência computacional.

NotaCondições de contorno e imagens finitas

Na morfologia matemática clássica, definida sobre um domínio infinito (tipicamente \(\mathbb{Z}^2\)), essa dualidade é exata. Em imagens digitais, entretanto, trabalha-se com matrizes finitas, e o resultado passa a depender da forma como são tratados os pixels localizados fora da imagem.

Para que as identidades de dualidade permaneçam válidas, o complemento deve ser definido em relação ao mesmo universo e as condições de contorno adotadas para a erosão e para a dilatação devem ser complementares entre si. Por exemplo, se a erosão assume que os pixels externos pertencem ao objeto (\(255\)), então a dilatação aplicada ao complemento deve assumir que esses mesmos pixels externos pertencem ao fundo (\(0\)).

Quando diferentes estratégias de preenchimento são utilizadas (replicação, reflexão, valor constante etc.), a dualidade teórica pode deixar de ser satisfeita exatamente nas regiões próximas às bordas da imagem.

Para ilustrar numericamente os operadores morfológicos e a dualidade erosão–dilatação, a Figura 4.4 apresenta uma imagem binária 10×10 processada com um elemento estruturante em formato de “L”. Na implementação de morph.py, a origem de \(B\) é fixada no centro geométrico da máscara — posição \((1,1)\) para um kernel 3×3 — e deve corresponder a um elemento ativo para que a erosão se comporte corretamente (conforme discutido anteriormente). O elemento estruturante \(B_L\) definido a seguir satisfaz essa condição. A Figura 4.5 complementa a análise com um simulador interativo da erosão, permitindo visualizar o deslocamento do elemento estruturante sobre a imagem e identificar as posições em que ele permanece completamente contido no objeto.

A = np.array([
    [0,0,0,0,0,0,0,0,0,0],
    [0,0,0,1,1,1,0,0,0,0],
    [0,0,1,1,1,1,1,0,0,0],
    [0,1,1,1,1,1,1,1,0,0],
    [0,1,1,1,1,1,1,1,0,0],
    [0,1,1,1,1,1,1,0,0,0],
    [0,0,1,1,1,1,1,1,0,0],
    [0,0,0,1,1,1,1,0,0,0],
    [0,0,0,0,1,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0]], dtype=np.uint8) * 255

B_L = np.array([[1,0,0],
                [1,1,0],
                [1,1,0]], dtype=np.uint8)   # origem no centro (1,1), elemento ativo

# Elemento estruturante refletido B̂
B_hat = np.flip(B_L)
print("Elemento estruturante B_L:")
print(mm.drawImg(B_L))
print("Elemento estruturante refletido B̂:")
print(mm.drawImg(B_hat))    

# Erosão de A por B_L
img_ero0 = mm.ero0(A, B_L)

# Validação da dualidade: (A ⊖ B)^c = A^c ⊕ B̂
A_c   = 255 - A           # complemento de A
ero_c = 255 - img_ero0    # complemento da erosão — lado esquerdo
dil_Ac = mm.dil0(A_c, B_hat)             # lado direito

dualidade_ok = np.array_equal(ero_c, dil_Ac)
print(f"Dualidade (A ⊖ B)^c == A^c ⊕ B̂ : {dualidade_ok}")
# Transposto (reflexão em ambos os eixos): B̂ usado na dilatação da dualidade
B_hat = np.flip(B_L)
mm.show(
    [A, A_c, img_ero0, ero_c, dil_Ac],
    titles=["A", "Aᶜ", "A ⊖ B", "(A ⊖ B)ᶜ", "Aᶜ ⊕ B̂"],
    cols=5,
    figsize=(15, 3)
)
Elemento estruturante B_L:
1 0 0 
1 1 0 
1 1 0 

Elemento estruturante refletido B̂:
0 1 1 
0 1 1 
0 0 1 

Dualidade (A ⊖ B)^c == A^c ⊕ B̂ : True
Figura 4.4: Erosão e dilatação em imagem binária 10×10 com elemento estruturante ‘L’ assimétrico 3×3. Validação da dualidade erosão–dilatação.
Simulador: Erosão Morfológica A ⊖ B_L · offsets via _viz
Posição X (col)
4
Posição Y (lin)
4
Pixel Erosão
255
clique numa célula para mover o kernel B_L
Objeto A A ⊖ B (erosão) B ativo (dentro) B ativo (fora) B inativo
Controles
4
4
Sucesso: contido!
Pixel recebe 1 (255) na imagem erodida.
Kernel B_L (3×3)
1
0
0
1
0
1
1
0
ativo  ·  origem (inativa)  ·  inativo
_viz offset formula:
vy = y + by − ⌊Bh/2⌋
vx = x + bx − ⌊Bw/2⌋
Figura 4.5: Simulador interativo de erosão morfológica: visualização do critério de inclusão do elemento estruturante assimétrico \(B_L\) sobre a imagem binária \(A\).

4.3.2 Abertura e Fechamento

Combinando erosão e dilatação obtêm-se dois operadores de grande utilidade prática: a abertura e o fechamento, definidos pelas Equações Equação 4.5 e Equação 4.6. Seus principais efeitos são resumidos na Tabela 4.2.

Abertura (opening) — erosão seguida de dilatação pelo mesmo \(B\):

\[ A \circ B = (A \ominus B) \oplus B \tag{4.5}\]

Fechamento (closing) — dilatação seguida de erosão pelo mesmo \(B\):

\[ A \bullet B = (A \oplus B) \ominus B \tag{4.6}\]

Na prática, o OpenCV aplica o mesmo elemento estruturante nas duas etapas. Para elementos estruturantes simétricos (os mais comuns), essa implementação coincide com a definição matemática apresentada acima.

Tabela 4.2: Propriedades de abertura e fechamento.
Operador Sequência Efeito principal
Abertura \(A \circ B\) erosão → dilatação Remove estruturas incapazes de conter o elemento estruturante; suaviza contornos externos
Fechamento \(A \bullet B\) dilatação → erosão Preenche buracos menores que \(B\); suaviza contornos internos

Propriedade importante: ambos são idempotentes. Por exemplo,

\[ (A \circ B) \circ B = A \circ B, \]

ou seja, após a primeira aplicação, novas aplicações do mesmo operador não alteram mais o resultado.

4.3.2.1 Implementação da abertura e do fechamento

Diferentemente da erosão e da dilatação, abertura e fechamento não introduzem novos mecanismos computacionais. Ambos são obtidos pela composição sequencial dos operadores primitivos já apresentados:

def open0(f, B):
    return mm.dil0(mm.ero0(f, B), B)

def close0(f, B):
    return mm.ero0(mm.dil0(f, B), B)

A função mm.open delega a operação para cv2.morphologyEx(..., MORPH_OPEN, B), enquanto mm.close utiliza cv2.morphologyEx(..., MORPH_CLOSE, B), produzindo o mesmo resultado de forma mais eficiente.

A abertura herda da erosão a capacidade de remover estruturas menores que o elemento estruturante e da dilatação a restauração parcial das regiões preservadas. O fechamento realiza o processo inverso: primeiro expande os objetos e depois restaura suas dimensões originais, preenchendo lacunas e buracos menores que o elemento estruturante.

4.3.2.2 Filtro Sequencial Alternado

Na prática, abertura e fechamento são frequentemente aplicados em sequência para remover simultaneamente ruído externo e preencher buracos internos. A função mm.asf (Alternating Sequential Filter) generaliza essa estratégia ao aplicar aberturas e fechamentos alternadamente com elementos estruturantes progressivamente maiores. As sequências disponíveis são apresentadas na Tabela 4.3.

Tabela 4.3: Sequências do filtro sequencial alternado mm.asf.
Sequência Ordem Uso típico
'OC' abertura → fechamento remove ruído externo antes de preencher pequenos buracos
'CO' fechamento → abertura preenche pequenos buracos antes de remover ruído externo
'OCO' abertura → fechamento → abertura enfatiza a remoção de ruído externo
'COC' fechamento → abertura → fechamento enfatiza o preenchimento de buracos e lacunas

O parâmetro n controla o número de escalas utilizadas pelo filtro. Em cada iteração \(i\), o elemento estruturante é ampliado por soma de Minkowski (mm.sesum(b, i)), produzindo uma sequência de filtros morfológicos cada vez mais abrangentes. Diferentemente de uma única abertura ou fechamento com um elemento estruturante grande, o ASF realiza uma suavização progressiva em múltiplas escalas, preservando melhor a geometria dos objetos relevantes enquanto elimina estruturas menores. A Figura 4.6 apresenta um exemplo de aplicação da abertura, do fechamento e do ASF na imagem das moedas.

img_bin    = mm.threshold(img_clahe)
B_disk     = mm.sedisk(19)

img_open  = mm.open(img_bin, B_disk)             # erosão → dilatação
img_close = mm.close(img_bin, B_disk)            # dilatação → erosão
img_oc    = mm.close(img_open, B_disk)           # abertura seguida de fechamento
img_asf   = mm.asf(img_bin, 'OC', mm.sedisk(3), n=7)  
# ASF: disco base 3×3, cresce a cada iteração

mm.show(
    [img_bin, img_open, img_close, img_oc, img_asf],
    titles=["Binarização Otsu (CLAHE)", "Abertura (A∘B)", "Fechamento (A∙B)",
            "Abertura→Fechamento", "ASF-OC (n=7)"],
    cols=5, figsize=(15, 12)
)
Figura 4.6: Abertura, fechamento, composição e filtro sequencial alternado aplicados à binarização Otsu das moedas com CLAHE. Elemento estruturante: disco 13×13.

4.3.3 Operadores Geodésicos

Os operadores geodésicos introduzem uma restrição adicional aos operadores morfológicos clássicos por meio de uma imagem de controle denominada máscara \(g\). Em vez de permitir que a erosão ou a dilatação se propaguem livremente pela imagem, o resultado de cada iteração é limitado ponto a ponto pelos valores da máscara, restringindo a evolução da operação às regiões permitidas.

4.3.3.1 Dilatação Geodésica

A dilatação geodésica de uma imagem marcador \(f\) sob uma imagem máscara \(g\), utilizando um elemento estruturante plano \(b\), é definida por:

\[ f \oplus_g b = (f \oplus b) \wedge g, \tag{4.7}\]

onde \(\wedge\) representa o mínimo ponto a ponto.

Em outras palavras, realiza-se inicialmente uma dilatação convencional sobre o marcador e, em seguida, o resultado é restringido pela máscara \(g\). Dessa forma, a propagação nunca pode ultrapassar as regiões permitidas pela máscara.

A formulação clássica da dilatação geodésica pressupõe que o marcador esteja contido na máscara, isto é, \(f \le g\), garantindo que a evolução da operação permaneça sempre limitada pela máscara.

4.3.3.2 Implementação da dilatação geodésica

A função mm.cdil implementa diretamente esse operador e permite executar múltiplas iterações consecutivas:

def cdil(f, g, b=np.zeros((3,3),dtype='uint8'), n=1):
    """Dilatação geodésica do marcador f sob a máscara g."""
    y = f.copy()
    for _ in range(n):
        y = np.minimum(cv2.dilate(y, b), g)
    return y

A instrução np.minimum(cv2.dilate(y, b), g) implementa exatamente a definição matemática da dilatação geodésica, isto é, \((y \oplus b)\wedge g\).

Quando \(n=1\), a função executa uma única dilatação geodésica. Para \(n>1\), o resultado de cada etapa torna-se o marcador da etapa seguinte, produzindo uma propagação progressiva controlada pela máscara.

O simulador interativo Figura 4.7 permite acompanhar, passo a passo, a propagação do marcador \(f\) ao longo dos corredores do labirinto. A cada iteração de mm.cdil, a frente de dilatação avança para as células vizinhas livres — aquelas em que \(g = 1\) —, enquanto as paredes (\(g = 0\)) permanecem intransponíveis. O número de passos necessários para que o marcador alcance a saída corresponde exatamente ao comprimento geodésico do caminho mais curto dentro da máscara, evidenciando a conexão direta entre a dilatação geodésica iterada e a noção de distância em grafos.

Parede
Caminho livre (máscara g)
Marcador f (passo atual)
Propagação anterior
Saída
Passo 0 — marcador inicial f (entrada)
Figura 4.7: Simulador interativo de dilatação geodésica: o marcador \(f\) (verde) propaga-se passo a passo pelos caminhos livres da máscara \(g\), sem atravessar paredes — ilustrando como \(\delta_g^{(n)}(f)\) encontra a saída do labirinto.

4.3.3.3 Erosão Geodésica

De forma dual, a erosão geodésica de uma imagem marcador \(f\) sob uma imagem máscara \(g\) é definida por:

\[ f \ominus_g b = (f \ominus b) \vee g, \tag{4.8}\]

onde \(\vee\) representa o operador de máximo ponto a ponto.

Nesse caso, a erosão convencional do marcador é seguida por uma restrição inferior imposta pela máscara. Assim, nenhum pixel do resultado pode assumir valor inferior ao correspondente pixel da máscara.

A formulação clássica da erosão geodésica pressupõe a condição dual

\[ f \ge g, \]

de modo que a máscara atue como limite inferior durante todo o processo.

4.3.3.4 Implementação da erosão geodésica

A função estática mm.cero materializa esse operador:

@staticmethod
def cero(f, g, b=np.zeros((3,3),dtype='uint8'), n=1):
    """Erosão geodésica do marcador f sob a máscara g."""
    y = f.copy()
    for _ in range(n):
        y = np.maximum(cv2.erode(y, b), g)
    return y

A instrução np.maximum(cv2.erode(y, b), g) implementa diretamente a expressão \((y \ominus b)\vee g\).

Assim como na dilatação geodésica, o parâmetro \(n\) define quantas erosões geodésicas sucessivas serão computadas antes de retornar a imagem final.

NotaRelação com a reconstrução morfológica

A reconstrução morfológica apresentada na próxima seção é obtida pela aplicação iterativa da dilatação geodésica (mm.cdil) até que um ponto fixo seja alcançado, isto é, até que nenhuma célula mude de valor entre duas iterações consecutivas. Em outras palavras, a reconstrução consiste em uma sequência de dilatações geodésicas sucessivas que se propagam dentro da máscara até não haver mais alterações.

De forma dual, também é possível definir reconstruções baseadas em erosão geodésica por meio de aplicações sucessivas de mm.cero.

4.3.3.5 Exemplo: propagação em um labirinto via dualidade

A Figura 4.8 ilustra a resolução do problema de conectividade de um labirinto utilizando a dualidade morfológica por meio das funções mm.cero e mm.suprec.

Em vez de propagar um marcador pelos corredores livres através de dilatações geodésicas, o problema é formulado no domínio complementar. Inicialmente, a máscara original é invertida,

\[ g = 1 - g_{orig}, \]

fazendo com que as paredes passem a assumir valor 1 e os corredores valor 0. De forma análoga, o marcador é construído nesse mesmo domínio complementar, contendo um único valor 0 na posição de entrada do labirinto e valor 1 nos demais pixels.

Utilizando o elemento estruturante em cruz (mm.secross()), a erosão geodésica atua sobre o marcador complementado. A cada iteração de mm.cero, a região conectada ao marcador inicial sofre erosões sucessivas, enquanto a máscara impõe um limite inferior que impede a propagação através das paredes do labirinto.

As imagens intermediárias mostram estados da evolução após diferentes números de iterações (n=5, n=12 e n=22). O resultado final é obtido pela reconstrução geodésica por erosão (mm.suprec), que aplica erosões geodésicas sucessivas até atingir um ponto fixo, isto é, uma situação em que nenhuma alteração adicional ocorre entre duas iterações consecutivas.

No domínio complementar, a região reconstruída corresponde exatamente ao conjunto de corredores conectados à entrada do labirinto. Assim, a conectividade entre entrada e saída pode ser determinada diretamente a partir da imagem reconstruída.

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.colors as mcolors

# 1. Máscara original g (0 = Parede, 1 = Corredor)
g_orig = np.array([
    [0, 1, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 1, 1, 1, 1, 1, 0, 1, 1, 1],
    [0, 0, 0, 0, 0, 1, 0, 1, 0, 1],
    [0, 1, 1, 1, 0, 1, 1, 1, 0, 1],
    [0, 1, 0, 1, 0, 0, 0, 0, 0, 1],
    [0, 1, 0, 1, 1, 1, 1, 1, 1, 1],
    [0, 1, 0, 0, 0, 0, 0, 0, 1, 0],
    [0, 1, 1, 1, 1, 1, 1, 0, 1, 0],
    [0, 0, 0, 0, 0, 0, 1, 1, 1, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 1, 0]], dtype=np.uint8)

# Inversão global no início (Dualidade Morfológica)
g = 1 - g_orig  # Agora: 1 = Parede, 0 = Corredor
f = np.ones((10, 10), dtype=np.uint8)
f[0, 1] = 0     # Semente injetada como 0 no corredor

# Elemento estruturante em cruz
B_cruz = mm.secross()

# Processamento direto usando mm.cero e mm.suprec no domínio invertido
passo_5    = mm.cero(f, g, B_cruz, n=5)
passo_12   = mm.cero(f, g, B_cruz, n=12)
passo_22   = mm.cero(f, g, B_cruz, n=22)
ponto_fixo = mm.suprec(f, g, B_cruz)

# --- CONFIGURAÇÃO DA EXIBIÇÃO GRÁFICA ---

titles = [
    "Máscara (~g)", "Marcador (~f)", 
    "Avanço (n=5)", "Avanço (n=12)", 
    "Avanço (n=22)", "~mm.suprec"
]
images = [g, f, passo_5, passo_12, passo_22, ponto_fixo]

fig, axes = plt.subplots(1, 6, figsize=(16, 4), facecolor='#fcfcfc')

# Mapa de cores adaptado para o domínio complementar:
# No domínio invertido: 1 = Parede (Azul Escuro)
# Onde g == 0 e imagem == 1 = Corredor Livre (Cinza Claro)
# Onde g == 0 e imagem == 0 = Onda Geodésica Ativa (Ouro)
cmap_pipeline = mcolors.ListedColormap(['#ffc13b', '#e0e0e0', '#1e3d59'])

for i, ax in enumerate(axes):
    if i == 0 or i == 1:
        # Para as condições iniciais (~g e ~f)
        cmap_init = mcolors.ListedColormap(['#e0e0e0', '#1e3d59'])
        ax.imshow(images[i], cmap=cmap_init, vmin=0, vmax=1)
    else:
        # Renderização baseada nos estados complementares
        render_step = np.zeros_like(g, dtype=np.uint8)
        render_step[g == 1] = 2           # Parede (1 original do mapa de cores)
        render_step[g == 0] = 1           # Corredor padrão
        render_step[images[i] == 0] = 0   # Onda ativa (0 original do mapa de cores)
        ax.imshow(render_step, cmap=cmap_pipeline, vmin=0, vmax=2)
    
    ax.set_title(titles[i], fontsize=10, fontweight='bold', color='#1e3d59', pad=12)
    ax.set_xticks(np.arange(-0.5, 10, 1), minor=True)
    ax.set_yticks(np.arange(-0.5, 10, 1), minor=True)
    ax.grid(which='minor', color='#ffffff', linestyle='-', linewidth=1)
    ax.tick_params(which='both', bottom=False, left=False, labelbottom=False, labelleft=False)
    
    # Indicadores gráficos de Entrada e Saída
    ax.plot(1, 0, marker='v', color='#2ecc71', markersize=7, markeredgewidth=1.5)
    ax.plot(8, 9, marker='o', color='#e74c3c', markersize=6, fillstyle='none', markeredgewidth=2)

plt.tight_layout()
plt.show()
Figura 4.8: Resolução de labirinto via Operações Complementares: Invertendo a máscara e o marcador no início do pipeline, o fluxo é resolvido diretamente no domínio complementar através de mm.cero e mm.suprec, eliminando re-inversões redundantes.

4.3.4 Reconstrução Morfológica

A reconstrução morfológica propaga uma imagem marcador \(f\) dentro de uma imagem máscara \(g\), garantindo que o resultado nunca ultrapasse os valores de intensidade impostos pela máscara. O operador fundamental que viabiliza essa propagação contida é a dilatação geodésica, definida pela Equação 4.7.

A reconstrução é obtida pela aplicação iterativa dessa dilatação condicionada. Inicialmente, o marcador é limitado pela máscara para estabelecer o estado inicial:

\[ X^{(0)} = f \wedge g, \]

e as iterações subsequentes são definidas de forma recursiva por:

\[ X^{(k)} = (X^{(k-1)} \oplus b) \wedge g. \]

A sequência cresce monotonicamente até atingir um ponto fixo, produzindo a reconstrução morfológica por dilatação (também conhecida na literatura como inf-reconstrução):

\[ R_g^\delta(f) = \lim_{k\to\infty} X^{(k)} = X^{(k)} \quad \text{quando} \quad X^{(k)} = X^{(k-1)}. \tag{4.9}\]

A subida iterativa é interrompida assim que a estabilidade é alcançada, isto é, quando duas iterações consecutivas produzem matrizes com valores absolutamente idênticos.

4.3.4.1 Implementação da reconstrução morfológica

A rotina didática mm.infrec implementa diretamente o algoritmo iterativo de ponto fixo. Inicialmente, o marcador efetivo inicial \(X^{(0)}\) é determinado pela operação np.minimum(f, g). Para garantir que o laço de verificação execute a primeira passada sem disparar falsas convergências prematuras, a variável de controle da iteração anterior (y1) é inicializada preenchida com um valor sentinela fora do domínio de dados (ou simplesmente com uma matriz que force a primeira execução).

def infrec(f, g, b=np.zeros((3,3), dtype='uint8')):
    """Inf-reconstrução: dilata o marcador (f ∧ g) até convergir sob a máscara g."""
    y = np.minimum(f, g)
    # Inicializa y1 com valores impossíveis para forçar a entrada no laço
    y1 = np.full_like(f, 256, dtype=np.int16) 
    while not np.array_equal(y, y1):
        y1 = y.copy()
        # Aplica a dilatação geodésica: (y ⊕ b) ∧ g
        y = np.minimum(cv2.dilate(y, b), g)
    return y.astype('uint8')

No interior do laço while, a variável y armazena a estimativa corrente da reconstrução \(X^{(k)}\), enquanto y1 preserva a imagem do estágio imediatamente anterior \(X^{(k-1)}\). A instrução de controle condicional np.minimum(cv2.dilate(y, b), g) traduz fielmente a dilatação geodésica teórica, onde a expansão morfológica convencional comandada pelo OpenCV é imediatamente “podada” e limitada pelas barreiras de intensidade da máscara \(g\). O laço cessa quando nenhuma modificação de pixel é registrada entre os passos.

4.3.4.2 Vantagens da reconstrução morfológica

A reconstrução morfológica é significativamente mais robusta que a abertura convencional porque é capaz de remover estruturas indesejadas sem distorcer ou alterar a morfologia dos objetos que devem ser preservados.

Enquanto a abertura clássica suaviza cantos, elimina pontas e deforma contornos devido à imposição geométrica rígida do elemento estruturante, a reconstrução geodésica utiliza a máscara para recuperar com exatidão os limites e formatos originais dos objetos que possuem conectividade com o marcador original.

Em termos intuitivos, o marcador atua como uma semente de contágio que se expande progressivamente, mas apenas trafegando pelas regiões permitidas pela máscara. Componentes que não possuem nenhuma intersecção com o marcador jamais serão reconstruídas (sendo eliminadas), enquanto as componentes tocadas pela semente expandem-se até restaurar integralmente a sua geometria original.

Esse comportamento discriminatório e conservativo é ilustrado na Figura 4.9, utilizando o elemento estruturante em cruz apresentado na Figura 4.3.

# 1. Definição da Máscara (g): Imagem 10x10 com dois objetos isolados
g = np.array([
    [0,0,0,0,0,0,0,0,0,0],
    [0,1,1,1,0,0,0,1,1,0],
    [0,1,1,1,1,0,0,1,1,0],
    [0,1,1,1,1,0,0,0,0,0],
    [0,1,1,1,1,0,0,0,0,0],
    [0,1,1,1,0,0,0,0,0,0],
    [0,0,1,1,1,0,0,1,0,0],
    [0,0,1,1,1,0,0,1,1,0],
    [0,0,0,1,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0]], dtype=np.uint8) * 255

B_cruz = mm.secross()

# 2. Geração do Marcador (f)
f = mm.ero(g, mm.sebox())

# 3. Iterações da Dilatação Condicionada automatizadas em laço
iteracoes = []
titulos_iteracoes = []
img_atual = f.copy()

for i in range(1, 6):
    img_add = img_atual*80
    img_atual = mm.cdil(img_atual, g, B_cruz)
    iteracoes.append(img_add+img_atual)
    titulos_iteracoes.append(f"Dilatação Cond. (n={i})")

# Reconstrução Geodésica Final via biblioteca (ponto de controle)
img_reconstruida = mm.infrec(f, g, B_cruz)

# Verificação de convergência (o passo 5 deve ser idêntico à reconstrução final)
convergencia_ok = np.array_equal(img_reconstruida, iteracoes[-1])
print(f"✅ Reconstrução alcançou estabilidade na iteração 5: {convergencia_ok}")

# 4. Exibição do pipeline evolutivo (Montagem dinâmica das listas)
mm.show(
    [g, f] + iteracoes + [img_reconstruida],
    titles=["Máscara (g)", "Marcador (f)"] + titulos_iteracoes + ["Reconstrução Final R_g(f)"],
    cols=8,
    figsize=(18, 3)
)
✅ Reconstrução alcançou estabilidade na iteração 5: False
Figura 4.9: Pipeline de Reconstrução Morfológica por Dilatação Condicionada: a máscara contém dois objetos, o marcador isola apenas o núcleo do objeto principal, e as iterações subsequentes em laço reconstroem sua forma exata até a convergência.

4.3.5 Preenchimento de Buracos e Remoção de Bordas

Dois operadores baseados em reconstrução morfológica completam o pipeline de limpeza binária. Suas características principais são resumidas na Tabela 4.4.

Preenchimento de buracos (mm.clohole) remove cavidades completamente cercadas pelo objeto, independentemente do tamanho, sem alterar os contornos externos. O procedimento atua no complemento da imagem, utilizando como marcador uma restrição da moldura (frame) ao fundo:

\[ \text{clohole}(f) = \bigl(R_{f^c}^\delta(\text{frame}(f) \wedge f^c)\bigr)^c \tag{4.10}\]

Em termos operacionais, primeiro reconstrói-se o fundo externo e, em seguida, aplica-se a complementação para recuperar os objetos com buracos preenchidos.

Remoção de objetos de borda (mm.edgeoff) elimina todos os objetos que tocam a borda da imagem, preservando apenas os componentes totalmente internos. O marcador é obtido pela interseção entre a moldura (frame) e os objetos da imagem:

\[ \text{edgeoff}(f) = f \setminus R_f^\delta(\text{frame}(f) \wedge f) \tag{4.11}\]

Tabela 4.4: Comparação entre os operadores clohole e edgeoff.
Operador Marcador Máscara Efeito
mm.clohole frame restrito ao fundo (\(f^c\)) \(f^c\) Preenche buracos internos
mm.edgeoff frame restrito ao objeto (\(f\)) \(f\) Remove objetos conectados à borda

A evolução passo a passo dessas transformações geodésicas pode ser acompanhada nas figuras a seguir. A Figura 4.10 ilustra o mecanismo de inundação controlada do operador mm.clohole, no qual a reconstrução ocorre a partir do fundo externo e impede a propagação para regiões internas não conectadas ao exterior, resultando no preenchimento consistente das cavidades internas. Em contrapartida, a Figura 4.11 detalha a dinâmica do operador mm.edgeoff, em que apenas os componentes conectados à borda são reconstruídos e posteriormente removidos, preservando exclusivamente os objetos totalmente contidos no interior da imagem.

A conectividade da propagação geodésica é controlada pelo elemento estruturante: mm.sebox() (vizinhança de 8) inclui conexões diagonais, enquanto mm.secross() (vizinhança de 4) as exclui. Consequentemente, a escolha do elemento estruturante afeta quais componentes são alcançados pela reconstrução e, portanto, quais serão preservados ou removidos.

4.3.5.1 Conformidade com a implementação

As definições acima estão diretamente alinhadas com a implementação em morph.py, reproduzida a seguir:

@staticmethod
def clohole(f, b=np.ones((3,3),dtype='uint8')):
    # marcador restrito ao fundo da imagem
    marcador = mm.frame(f, border=1) & mm.neg(f)
    return mm.neg(mm.infrec(marcador, mm.neg(f), b))

@staticmethod
def edgeoff(f, b=np.ones((3,3),dtype='uint8')):
    # marcador restrito aos objetos da imagem
    marcador = mm.frame(f, border=1) & f
    return mm.subm(f, mm.infrec(marcador, f, b))

Essas implementações deixam explícito que ambos os operadores são instâncias diretas de reconstrução morfológica por dilatação geodésica com mm.infrec, diferindo apenas na escolha do marcador e da máscara: clohole atua no complemento da imagem, enquanto edgeoff atua diretamente no domínio dos objetos.

# Função auxiliar unificada para gerar o pipeline iterativo com realce visual
def gerar_pipeline_reconstrucao(marcador, mascara, kernel, passos=4, fator=80):
    iteracoes, titulos = [], []
    img_atual = marcador.copy()
    for i in range(1, passos + 1):
        img_visual = img_atual * fator
        img_atual = mm.cdil(img_atual, mascara, kernel)
        iteracoes.append(img_visual + img_atual)
        titulos.append(f"Iter. (n={i})")
    return iteracoes, titulos

# Imagem binária 10x10 com 3 cenários: objeto com buraco, objeto isolado e objeto na borda
f = np.array([
    [0,0,0,0,0,0,0,0,0,0],
    [0,1,1,1,1,0,0,0,0,1],
    [0,1,0,0,1,0,0,1,0,1],
    [0,1,0,0,1,0,0,1,0,1],
    [0,1,1,1,1,0,0,1,0,0],
    [0,0,0,0,0,0,0,0,0,0],
    [0,0,1,1,1,0,1,1,1,0],
    [0,0,1,1,1,0,1,0,1,0],
    [0,0,1,1,1,0,1,1,1,0],
    [0,0,0,0,0,0,0,0,0,1]], dtype=np.uint8) * 255

B_cruz = mm.secross()
f_c = mm.neg(f)              # Utiliza o operador de negação nativo da mm

# ── PIPELINE CLOHOLE ──────────────────────────────────────────────────────────
# O marcador do clohole teórico é a borda externa 
marcador_ch = mm.frame(f, border=1) 
iters_ch, tits_ch = gerar_pipeline_reconstrucao(marcador_ch, f_c, B_cruz, passos=5)

# Resultado final calculado por extenso e validado com a função nativa mm.clohole
img_clohole = mm.neg(mm.infrec(marcador_ch, f_c, B_cruz))
print(f"✅ Validação clohole: {np.array_equal(img_clohole, mm.clohole(f))}")

mm.show(
    [f, marcador_ch] + iters_ch + [img_clohole],
    titles=["f original", "Marcador (Borda)"] + tits_ch + ["clohole(f)"],
    cols=8, figsize=(18, 3), axis=True
)
✅ Validação clohole: True
Figura 4.10: Pipeline de preenchimento de buracos (clohole): o marcador é obtido a partir da borda da imagem e restringido ao complemento \(f^c\). As iterações de dilatação geodésica reconstruem o fundo externo; após a complementação final, os buracos internos ficam preenchidos.
# ── PIPELINE EDGEOFF ──────────────────────────────────────────────────────────
B_box = mm.sebox()
marcador_eo = marcador_ch & f
iters_eo, tits_eo = gerar_pipeline_reconstrucao(marcador_eo, f, B_box, passos=5)

# Resultado final por definição e validação nativa
img_edgeoff =  mm.edgeoff(f, B_box)
print(f"✅ Validação edgeoff: {np.array_equal(img_edgeoff, mm.edgeoff(f))}")

mm.show(
    [f, marcador_eo] + iters_eo + [img_edgeoff],
    titles=["f original", "Marcador (Borda)"] + tits_eo + ["edgeoff(f)"],
    cols=8, figsize=(18, 3)
)
✅ Validação edgeoff: True
Figura 4.11: Pipeline de eliminação de estruturas de borda (edgeoff): o marcador captura as raízes conectadas às extremidades da matriz, a reconstrução delimita a extensão desses elementos e a subtração booleana preserva unicamente os objetos totalmente internos.

4.3.6 Pipeline de Limpeza Binária com CLAHE

Com base na análise anterior — na qual o CLAHE produziu o maior valor da variância interclasses (\(\sigma_B^2 \approx 2{,}47 \times 10^3\)), ver Figura 4.2, e o operador mm.clohole mostrou-se eficaz no preenchimento das cavidades internas —, o pipeline final de segmentação, ilustrado na Figura 4.12, é estruturado pelo seguinte fluxo computacional:

\[ \text{gray} \xrightarrow{\text{CLAHE}} \xrightarrow{\text{Otsu}} \xrightarrow{\text{open}} \xrightarrow{\text{clohole}} \xrightarrow{\text{open}} \xrightarrow{\text{edgeoff}} \text{segmentação} \]

Após a etapa de mm.clohole, aplica-se uma segunda abertura morfológica com um elemento estruturante maior (mm.sedisk(33), disco de diâmetro 33). Essa operação remove pequenas regiões residuais e artefatos que possam ter permanecido após a segmentação. Em particular, o preenchimento geodésico pode transformar pequenas cavidades isoladas em componentes conectados ao objeto, tornando conveniente uma etapa adicional de filtragem baseada em tamanho. O diâmetro foi escolhido de modo que as moedas permaneçam capazes de conter o elemento estruturante, enquanto componentes significativamente menores sejam eliminados.

Nesta imagem, nenhuma moeda está conectada à borda da matriz. Consequentemente, a aplicação de mm.edgeoff não altera o resultado obtido após a segunda abertura. Ainda assim, essa etapa é mantida no pipeline por robustez, pois em outras imagens podem existir objetos parcialmente visíveis ou conectados às bordas, que devem ser removidos antes da etapa de análise.

DicaPor que a abertura após o clohole?

O operador mm.clohole preenche todas as cavidades fechadas presentes nos objetos segmentados. Em algumas situações, pequenas regiões indesejadas podem permanecer após essa etapa ou tornar-se conectadas aos objetos principais. A abertura morfológica subsequente remove componentes menores que o elemento estruturante, preservando as moedas devido ao seu tamanho significativamente maior.

# ── Pré-processamento: apenas CLAHE 
img_clahe0 = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)).apply(img_coins_gray)

# ── Etapa 1: Binarização Otsu
img_bin = mm.threshold(img_clahe0)

# ── Etapa 2: Abertura — remove ruídos brancos de fundo
img_open = mm.open(img_bin, mm.sedisk(9))

# ── Etapa 3: clohole — fecha todos os buracos internos
img_hole = mm.clohole(img_open)

# ── Etapa 4: Abertura (kernel grande) — remove artefatos do clohole
img_limpo = mm.open(img_hole, mm.sedisk(33))

# ── Etapa 5: edgeoff — remove objetos que tocam a borda 
img_final = mm.edgeoff(img_limpo, border=1)

mm.show(
    [img_clahe0,  img_bin,               img_open,
     img_hole,    img_limpo,             img_final],
    titles=["CLAHE",            "Otsu",                "Abertura (r=9)",
            "clohole",          "Abertura (r=33)",     "Final (edgeoff)"],
    cols=6, figsize=(18, 6)
)
Figura 4.12: Pipeline completo de segmentação com CLAHE: Otsu → abertura (r=9) → clohole → abertura (r=33) → edgeoff.

4.3.7 Morfologia em Tons de Cinza

Os operadores morfológicos estendem-se naturalmente para imagens em tons de cinza. Nessa formulação, a erosão e a dilatação passam a atuar diretamente sobre os níveis de intensidade da imagem. Para elementos estruturantes planos (\(b \equiv 0\)), a erosão corresponde ao mínimo local e a dilatação ao máximo local dentro da vizinhança definida pelo elemento estruturante.

A interpretação intuitiva é simples: a erosão escurece regiões ao substituir cada pixel pelo menor valor presente em sua vizinhança, enquanto a dilatação clareia regiões ao utilizar o maior valor disponível. A combinação desses operadores permite construir transformações capazes de realçar bordas, remover tendências de iluminação e destacar estruturas locais.

Três operadores derivados são especialmente úteis:

Gradiente morfológico — destaca bordas como a diferença entre dilatação e erosão:

\[ \text{grad}_B(f) = (f \oplus B) - (f \ominus B) \tag{4.12}\]

Top-hat — destaca estruturas brilhantes menores que o elemento estruturante (diferença entre a imagem original e sua abertura):

\[ \text{top-hat}_B(f) = f - (f \circ B) \tag{4.13}\]

Black-hat — destaca estruturas escuras menores que o elemento estruturante (diferença entre o fechamento e a imagem original):

\[ \text{black-hat}_B(f) = (f \bullet B) - f \tag{4.14}\]

O Top-hat extrai detalhes brilhantes que não sobrevivem à abertura, enquanto o Black-hat evidencia detalhes escuros removidos pelo fechamento. Já o gradiente morfológico realça transições abruptas de intensidade, produzindo uma representação semelhante à de um detector de bordas.

Para compreender o mecanismo desses operadores em nível local, a Figura 4.13 apresenta um simulador interativo de morfologia em tons de cinza. O simulador permite editar livremente o elemento estruturante, visualizar seu deslocamento sobre a imagem e acompanhar simultaneamente o perfil unidimensional das intensidades. Dessa forma, torna-se possível observar diretamente como a erosão seleciona mínimos locais, como a dilatação seleciona máximos locais e como o gradiente morfológico emerge da diferença entre esses dois operadores.

O botão localizado no canto superior direito permite alternar entre a visualização original em tons de cinza e uma representação pseudocolorida (colormap) apenas nos três tipos gradientes. A versão colorida facilita a percepção visual das variações de intensidade, tornando mais evidente a ação dos operadores morfológicos sobre máximos, mínimos e transições locais da imagem.

Os operadores apresentados estão disponíveis em morph.py por meio das funções mm.gradm, mm.tophat e mm.blackhat.

Simulador Avançado de Morfologia Matemática
Mova o mouse para atualizar o perfil 1D da linha correspondente
X (col)
Y (lin)
f(x,y)
valor
Elemento B (Clique nos pixels para editar)
Abertura (f∘B): dil(ero(f))
Fechamento (f•B): ero(dil(f))
Gradiente: dil(f)-ero(f)
Top-hat: f - (f∘B)
Black-hat: (f•B) - f
Perfil 1D da linha: Nenhuma (passe o mouse sobre a imagem)
Figura 4.13: Simulador interativo avançado de morfologia com elemento estruturante editável e perfil 1D.

A Figura 4.14 ilustra os efeitos desses operadores sobre a imagem das moedas e seus respectivos histogramas. Observe que a erosão desloca a distribuição para intensidades mais baixas, enquanto a dilatação a desloca para intensidades mais altas. O gradiente concentra valores nas regiões de contorno, e os operadores Top-hat e Black-hat produzem histogramas fortemente concentrados em baixos níveis de intensidade, pois apenas pequenas estruturas locais são realçadas.

import io
import matplotlib.pyplot as plt
import numpy as np

def fig2img(fig):
    b = io.BytesIO(); fig.savefig(b, format='png', dpi=100); plt.close(fig); b.seek(0)
    return (plt.imread(b)[:, :, :3] * 255).astype(np.uint8)

def plot_hist(img, title):
    fig, ax = plt.subplots(figsize=(4, 3))
    h = mm.hist(img)
    # CORREÇÃO: range usa len(h) dinamicamente para casar com o retorno da biblioteca
    ax.bar(range(len(h)), h, color='steelblue', width=1, edgecolor='steelblue')
    ax.set(xlim=(0, 255)); plt.tight_layout()
    return fig2img(fig)

# 1. Processamento morfológico base
B = mm.sedisk(19)
operadores = [
    ("Original", img_coins_gray),
    ("Erosão", mm.ero(img_coins_gray, B)),
    ("Dilatação", mm.dil(img_coins_gray, B)),
    ("Gradiente Morf.", mm.gradm(img_coins_gray, B)),
    ("Top-hat", mm.tophat(img_coins_gray, B)),
    ("Black-hat", mm.blackhat(img_coins_gray, B))
]

# 2. Montagem dinâmica do par: [Imagem, Histograma]
imgs, titles = [], []
for nome, img in operadores:
    imgs += [img, plot_hist(img, f"Hist. {nome}")]
    titles += [nome, f"Hist. {nome}"]

# 3. Exibição final em grade de duas colunas (Imagem | Histograma)
mm.show(imgs, titles=titles, cols=4, figsize=(10, 8))
Figura 4.14: Morfologia em tons de cinza e seus respectivos histogramas. A erosão reduz intensidades locais, a dilatação as amplia, o gradiente destaca bordas, e os operadores Top-hat e Black-hat evidenciam detalhes locais brilhantes e escuros. Elemento estruturante: disco de diâmetro 19.

Os operadores morfológicos apresentados anteriormente serão agora utilizados como ferramentas de refinamento e geração de marcadores para métodos de segmentação mais avançados, apresentados a seguir.

4.4 Segmentação de Imagens: Fundamentação e Taxonomia

A segmentação de imagens consiste em dividir a imagem em regiões associadas a objetos ou estruturas de interesse. Em PDI, ela representa a transição entre o processamento de baixo nível — como filtragem e realce — e etapas de análise mais avançadas, como extração de características, reconhecimento e interpretação da cena.

Formalmente, o objetivo da segmentação consiste em decompor o domínio espacial completo de uma imagem, denotado por \(\Omega\), em uma partição de subconjuntos \(\{R_1, R_2, \ldots, R_n\}\) que satisfaça simultaneamente os critérios de completeza e disjunção:

\[ \bigcup_{i=1}^{n} R_i = \Omega, \qquad R_i \cap R_j = \emptyset \quad \forall\, i \neq j \tag{4.15}\]

Além das propriedades de completeza e disjunção expressas na Equação 4.15, cada sub-região \(R_i\) deve constituir um domínio homogêneo segundo um predicado de similaridade definido sobre propriedades locais — intensidade, cor ou textura — e, simultaneamente, ser distinta das regiões adjacentes.

As técnicas de segmentação podem ser organizadas em diferentes famílias. Neste capítulo serão enfatizadas as abordagens resumidas na Tabela 4.5, fundamentadas principalmente em critérios de intensidade, conectividade e proximidade espacial.

Tabela 4.5: Taxonomia simplificada das principais abordagens de segmentação e refinamento estudadas neste capítulo.
Abordagem Critério de Segmentação Operadores de Referência
Limiarização Particionamento do espaço de intensidades Critério de Otsu, limiarização global e local
Morfologia Matemática Relações espaciais definidas por elementos/funções estruturantes Erosão, dilatação, abertura, fechamento e reconstrução
Baseada em Regiões Homogeneidade local e conectividade espacial Rotulação de componentes conexos, Transformada de Distância e Watershed

Até este ponto, o desenvolvimento prático concentrou-se na limiarização, por meio da combinação entre equalização adaptativa CLAHE e o método global de Otsu. Essa etapa foi complementada por operadores de reconstrução morfológica baseados em dilatações geodésicas, implementados pelas funções mm.infrec, mm.clohole e mm.edgeoff, produzindo uma máscara binária limpa e adequada para análise.

Contudo, em cenários onde objetos distintos aparecem conectados na máscara binária — seja por contato físico, sobreposição parcial ou por pontes estreitas de pixels produzidas pela segmentação —, a limiarização deixa de ser suficiente para individualizar cada objeto. Nesses casos, múltiplos objetos passam a compor um único componente conexo, dificultando etapas posteriores de medição e interpretação.

Para superar essa limitação, as próximas seções introduzem três ferramentas complementares: a Rotulação de Componentes Conexos, a Transformada de Distância e o algoritmo de segmentação por Watershed baseado em marcadores. Em conjunto, essas técnicas permitem separar objetos adjacentes, identificar regiões individualmente e extrair descritores geométricos consistentes para análise quantitativa.

4.4.1 Rotulação

A rotulação de componentes conexas (connected component labeling) é o operador que atribui um identificador inteiro único a cada conjunto de pixels pertencentes à mesma componente conexa em uma imagem binária.

NotaDefinição formal

Dada uma imagem binária \(f\) e uma relação de conectividade definida por um elemento estruturante \(B\) (tipicamente conectividade-4 ou conectividade-8), o algoritmo de rotulação da Figura 4.15 produz uma imagem \(g\) na qual todos os pixels pertencentes à mesma componente conexa recebem o mesmo rótulo inteiro positivo, enquanto pixels pertencentes a componentes distintas recebem rótulos diferentes.

A conectividade define quais pixels são considerados vizinhos diretos de um pixel \((x,y)\). As definições mais utilizadas são:

  • Conectividade-4: considera apenas os quatro vizinhos ortogonais (norte, sul, leste e oeste).
  • Conectividade-8: considera os quatro vizinhos ortogonais e os quatro diagonais, totalizando oito vizinhos.

A escolha da conectividade influencia diretamente a formação das componentes conexas e, consequentemente, o resultado da rotulação, como ilustrado na Figura 4.17. Um exemplo adicional pode ser explorado interativamente no simulador apresentado na Figura 4.16.

A implementação em morph.py disponibiliza duas versões desse operador. A função mm.label0 reproduz explicitamente o algoritmo de flood-fill utilizando uma pilha e permite controlar a conectividade por meio do elemento estruturante adotado. Já mm.label delega a operação à implementação otimizada do OpenCV (cv2.connectedComponents). Em ambos os casos, o resultado é uma imagem rotulada na qual cada componente conexa recebe um identificador inteiro distinto.

Algoritmo de rotulação por flood-fill com pilha — painel interativo com HTML e SVG

Passo a passo
Cards
Linha do tempo
Código Python
Fluxograma
Flood-fill com pilha
Rotulagem de componentes conexas
1
Criar imagem de saída g, inicializada com zeros, mesma dimensão de f.
2
Inicializar contador de rótulos (cor) cor ← 1.
3
Percorrer f em ordem raster (coordenadas x e y) até encontrar uma semente: pixel ativo (f[x,y] ≠ 0) ainda não rotulado (g[x,y] = 0).
4
Inserir a semente encontrada na pilha pilha ← [[x,y]].
5
Enquanto a pilha contiver coordenadas (while pilha):
Desempilhar pixel atual: i, j ← pilha.pop() e atribuir o rótulo: g[i,j] ← cor.
Buscar vizinhos usando o iterador mm._viz(f,b,i,j). Se o vizinho for ativo no elemento estruturante (bv ≠ 0), ativo na imagem (f[vy,vx] ≠ 0) e não rotulado (g[vy,vx] = 0), empilhá-lo.
6
Pilha vazia ⟹ Toda a componente conexa atual foi explorada e rotulada com sucesso.
7
Incrementar o rótulo para a próxima componente: cor ← cor + 1 e continuar a varredura raster.
A conectividade (4 ou 8 vizinhos) é definida unicamente pela matriz morfológica b passada como parâmetro, alterando os pixels retornados em mm._viz.
01
Inicialização
Criar matriz de rótulos g preenchida com zeros (fundo). Definir rótulo inicial cor ← 1.
02
Varredura Raster
Percorrer a matriz bidimensional linha por linha, localizando pixels pertencentes ao objeto que ainda não possuem rótulo.
03
Semente inicial
Ao achar um pixel válido, inicializar a estrutura LIFO de busca: pilha = [[x, y]].
04
Expansão por Flood-Fill
Enquanto houver elementos na pilha:
Extrair (i, j) via pop() e marcar g[i, j] = cor.
Inspecionar vizinhança geométrica e adicionar novos candidatos à pilha.
05
Próxima Componente
Pilha esvaziada ⟹ Incrementar indexador cor ← cor + 1 para diferenciar o próximo objeto isolado.
01
Alocação Espacial
g ← zeros_like(f) e definição do primeiro identificador: cor ← 1.
02
Varredura Bidimensional
Laços encadeados varrendo as dimensões h e w da imagem.
03
Descoberta de Objeto
Filtro condicional localiza pixel ativo não indexado e cria a pilha semente.
04–05
Preenchimento por Região (Flood-fill)
while pilha
Remover último da pilha (i,j) e aplicar rótulo atual.
Empilhar vizinhos conectados que atendam aos critérios morfológicos de b.
06
Fechamento do Objeto
Pilha vazia determina o fim do isolamento daquela componente.
07
Atualização do Rótulo
Incremento linear: cor ← cor + 1. A varredura raster continua do ponto onde parou.
label0.py flood-fill com pilha
def label0(f, b=np.ones((3,3),dtype='uint8')):
    """Rotulagem por flood-fill com pilha."""
    h, w = f.shape
    g = np.zeros(f.shape, dtype=int)
    cor = 1
    for x in range(h):
        for y in range(w):
            if f[x,y] and not g[x,y]:
                pilha = [[x,y]]
                while pilha:
                    i,j = pilha.pop(); g[i,j] = cor
                    for vy,vx,bv in mm._viz(f,b,i,j):
                        if bv and f[vy,vx] and not g[vy,vx]:
                            pilha.append([vy,vx])
                cor += 1
    return g
1
g = np.zeros(f.shape, dtype=int) — Inicializa a matriz de saída com zeros. Zeros representam o fundo invariável.
2
mm._viz(f, b, i, j) — O iterador morfológico avalia a conectividade. Passando B_cruz a busca expande em 4-vizinhança; passando quadrado (ones) expande em 8-vizinhança.
3
pilha.pop() — Remove o último par de coordenadas inserido, caracterizando um comportamento LIFO de busca em profundidade (DFS) para varrer o objeto de forma contígua.
4
cor += 1 — O incremento ocorre estritamente fora do laço while, garantindo que o mesmo número marque toda a extensão da componente concluída antes de passar para a próxima semente raster.
início inicializar saída g ← zeros(f.shape); cor ← 1 varredura raster próximo pixel (x, y) em f imagem toda varrida? fim f[x,y] ≠ 0 e g[x,y] = 0? inserir semente pilha ← [[x, y]] pilha vazia? i, j ← pilha.pop() g[i, j] ← cor inspecionar vizinhos se ativo e não rotulado → pilha.append([vy, vx]) cor ← cor + 1 próximo rótulo sim não sim não sim
Figura 4.15: Algoritmo de rotulação por flood-fill com pilha: passo a passo, cards, linha do tempo, código Python e fluxograma.

O auxiliar _viz itera sobre a janela estruturante b centrada em \((i,j)\), gerando somente os vizinhos válidos dentro dos limites da imagem — a conectividade desejada é inteiramente determinada pela forma de b passada ao algoritmo.

Exemplo didático — efeito da conectividade:

Simulador: rotulação de componentes conexas flood-fill com pilha
Pixels ativos
0
Componentes
0
Conectividade
4
Passo raster
clique para ativar/desativar pixels · arraste para pintar
Conectividade
4 vizinhos ortogonais: N, S, L, O
Visualização
Exemplos
Figura 4.16: Simulador interativo de rotulação de componentes conexas (connected component labeling): visualização da expansão flood-fill, conectividade-4 e conectividade-8.
# Imagem binária 10×10 com componentes diagonalmente adjacentes
f = np.array([
    [0,0,0,0,0,0,0,0,0,0],
    [0,1,0,0,0,0,0,0,0,0],
    [0,0,1,0,0,0,0,0,0,0],  # diagonal com linha anterior
    [0,0,0,1,0,0,1,1,0,0],
    [0,0,0,0,0,0,1,1,0,0],
    [0,0,0,0,0,0,0,0,0,0],
    [0,1,1,1,0,0,0,0,0,0],
    [0,1,0,1,0,0,0,1,0,0],
    [0,1,1,1,0,0,0,0,1,0],  # diagonal com linha anterior
    [0,0,0,0,0,0,0,0,0,0]], dtype=np.uint8) * 255

# Elemento estruturante cruz (conectividade-4) e quadrado (conectividade-8)
B4 = mm.secross()   # conectividade-4
B8 = mm.sebox()     # conectividade-8

# Rotulação com label0 (didático) e label (cv2)
lbl4_didatico = mm.label0(f, B4)
lbl8_didatico = mm.label0(f, B8)

_, lbl4_cv2 = cv2.connectedComponents(f, connectivity=4)
_, lbl8_cv2 = cv2.connectedComponents(f, connectivity=8)

# Validação cruzada
print(f"✅ label0 (C4) == cv2 (C4): {np.array_equal(lbl4_didatico, lbl4_cv2)}")
print(f"✅ label0 (C8) == cv2 (C8): {np.array_equal(lbl8_didatico, lbl8_cv2)}")
print(f"   Componentes C4: {lbl4_cv2.max()}  |  Componentes C8: {lbl8_cv2.max()}")

# Normalização para visualização
def norm_label(lbl):
    out = np.zeros_like(lbl, dtype=np.uint8)
    for i, v in enumerate(np.unique(lbl)[1:], 1):
        out[lbl == v] = int(i * 255 / lbl.max())
    return out

mm.show(
    [f, norm_label(lbl4_cv2), norm_label(lbl8_cv2)],
    titles=[
        "f original",
        f"Rotulação C4\n({lbl4_cv2.max()} componentes)",
        f"Rotulação C8\n({lbl8_cv2.max()} componentes)"
    ],
    cols=3, figsize=(12, 4), axis=True
)
✅ label0 (C4) == cv2 (C4): True
✅ label0 (C8) == cv2 (C8): True
   Componentes C4: 7  |  Componentes C8: 4
Figura 4.17: Efeito da conectividade na rotulação: a imagem binária 10×10 contém pixels diagonalmente adjacentes. Com conectividade-4, esses pixels formam componentes distintas; com conectividade-8, fundem-se em uma única componente.

4.4.2 Transformada de Distância

A Transformada de Distância (TD) é um operador que, aplicado a uma imagem binária \(f\), produz uma imagem em níveis de cinza \(D\) na qual cada pixel pertencente ao objeto (\(f(x,y)\neq 0\)) recebe como valor a distância geométrica até o pixel de fundo (\(f(x',y')=0\)) mais próximo:

\[ D(x,y) = \min_{(x',y') \,:\, f(x',y')=0} \; d\bigl((x,y),\,(x',y')\bigr) \]

onde \(d(\cdot,\cdot)\) é uma métrica de distância — tipicamente a distância Euclidiana (\(L_2\)). O resultado é uma representação topográfica dos objetos: pixels localizados no interior assumem valores elevados, enquanto pixels próximos às bordas apresentam baixos valores de distância. Os máximos locais de \(D\) correspondem aos pontos mais afastados da borda do objeto, frequentemente próximos aos seus centros geométricos ou centros de máxima inscrição — propriedade particularmente útil para a geração automática de marcadores no algoritmo watershed.

NotaDefinição formal usando erosões

A TD admite ainda uma interpretação morfológica iterativa, conforme algoritmo da Figura 4.18. Considere uma função estruturante \(b\) cujo valor central é nulo e cujos vizinhos possuem custos negativos associados ao deslocamento. Ao aplicar erosões sucessivas com essa função estruturante particular, os valores dos pixels dos objetos (que devem assumir a distância máxima possível da imagem) são progressivamente reduzidos segundo os custos definidos por \(b\). O valor acumulado dessa propagação passa então a representar a distância ao fundo segundo a métrica induzida pela função estruturante.

Essa interpretação é implementada em mm.dist1(), que acumula erosões sucessivas utilizando a operação mm.ero1(). Já mm.dist() delega o cálculo da distância Euclidiana ao operador otimizado do OpenCV cv2.distanceTransform(f, cv2.DIST_L2, 5), onde f é a imagem binária de entrada, cv2.DIST_L2 especifica a métrica Euclidiana (\(L_2\)) e 5 indica o uso de uma máscara 5×5 para aproximar a distância com elevada precisão.

A função dist1 produz uma transformada de distância discreta cuja métrica é determinada pela geometria e pelos pesos da função estruturante utilizada. Por exemplo, utilizando uma função estruturante em cruz com custo unitário para os quatro vizinhos ortogonais, obtém-se a distância de Manhattan (\(L_1\)). Outras escolhas de vizinhança e pesos induzem métricas diferentes. Já mm.dist() calcula uma aproximação eficiente da distância Euclidiana (\(L_2\)).

Por exigir sucessivas erosões sobre toda a imagem, a abordagem dist1 possui custo computacional significativamente maior que mm.dist(), sendo empregada neste livro principalmente para fins didáticos e para evidenciar a relação entre morfologia matemática e transformadas de distância.

Transformada de distância por erosões numéricas sucessivas — painel interativo

Passo a passo
Cards
Linha do tempo
Código Python
Fluxograma
Transformada de distância por erosão numérica
Propagação matemática de distâncias via elemento estruturante com pesos
1
Inicializar a imagem de trabalho fazendo uma cópia da original: g ← f.copy(). Os pixels de fundo (0) servem como fontes de distância nula.
2
Entrar em um laço infinito de erosões com pesos (ponto fixo):
Salvar estado anterior: f ← g.copy().
Erodir: g ← ero1(g, b), aplicando a subtração local de pesos e computando o valor mínimo para cada vizinhança.
3
Verificar convergência: se f for idêntica a g (array_equal), a frente de onda de distâncias se estabilizou. Romper o laço (break).
4
Retornar a matriz modificada g contendo o mapa exato de distâncias.
Nesta abordagem morfológica numérica, não há incremento artificial ou contador. A distância propaga-se de fora para dentro porque a erosão contínua puxa o valor 0 do fundo e o decrementa matematicamente (subtraindo os pesos negativos como -1), fazendo com que os valores escalem radialmente.
Cruz — L₁ (Manhattan)
B_cruz [y,x]
Pesos: Centro=0, Lados=-1, Cantos=-inf
Erosão de Cinzas
f[vy,vx] - bv
Subtrai o peso e busca o valor mínimo local
Convergência
f == g
Para quando nenhum pixel muda de valor
01
Inicialização
Clonar imagem de entrada: g ← f.copy(). O objeto possui intensidade alta (255) e o fundo possui intensidade 0.
02
Mapeamento Local (ero1)
Para cada coordenada (y, x), buscar o mínimo valor da operação f[vy, vx] - bv aplicada à sua vizinhança estruturante.
03
Loop Iterativo
Atualizar sequencialmente: f = g.copy() seguido de g = ero1(g, b). Os valores nulos propagam-se para o interior do objeto.
04
Critério de Parada
Se np.array_equal(f, g), significa que o mapa de distâncias atingiu o equilíbrio estável e a propagação terminou.
01
Cópia de Trabalho
Prepara a matriz inicial `g`.
02
Loop de Erosão de Escala
while True
Guarda estado: f ← g.copy()
Aplica erosão com pesos: g ← ero1(g, b)
03
Estabilização Espacial
Condição de parada acionada assim que np.array_equal(f, g) se torna verdadeiro.
04
Retorno Numérico
Retorna g contendo as distâncias calculadas pela subtração cumulativa dos pesos.
morph_dist.py Erosão numérica iterativa
@staticmethod
def ero1(f, b):
    g = np.empty_like(f)
    for y in range(f.shape[0]):
        for x in range(f.shape[1]):
            g[y,x] = 255
            for vy,vx,bv in mm._viz(f,b,y,x):
                if np.isinf(bv): continue 
                val = int(f[vy,vx]) - int(bv)
                if g[y,x] > val: 
                    g[y,x] = max(0, val)
    return g

@staticmethod
def dist1(f, b):
    g = f.copy()
    while True:
        f = g.copy()
        g = mm.ero1(g, b)
        if np.array_equal(f, g): 
            break
    return g
1
g[y,x] = 255 — Inicializa o elemento com o valor máximo antes de computar o operador de mínimo da erosão.
2
f[vy,vx] - bv — Subtrai o peso associado da vizinhança. Como os pesos da cruz externa são negativos (ex: -1), a operação torna-se uma adição matemática (f[vy,vx] - (-1) = f[vy,vx] + 1) propagando a distância a partir das bordas zeradas.
3
np.array_equal(f, g) — Critério de convergência exato por estabilização de ponto fixo.
início inicializar mapa g ← f.copy() salvar estado anterior f ← g.copy() executar erosão com pesos g ← ero1(g, b) array_equal(f, g) estabilizou? não sim retornar g fim Ponto Fixo Numérico: A distância emerge da propagação matemática do valor zero.
Figura 4.18: Algoritmo da Transformada de Distância: passo a passo, cards, linha do tempo, código Python e fluxograma.

A Figura 4.19 apresenta um simulador interativo da TD: é possível posicionar o cursor sobre diferentes pixels do objeto e observar, em tempo real, o valor da distância associado àquela posição, isto é, a distância até o pixel de fundo mais próximo. A Figura 4.20 apresenta um exemplo prático dessa execução em ambiente Python.

Simulador: Transformada de Distância (TD) Fronteiras em +∞ (144)
Pixels Ativos
0
Distância Máx.
0
Métrica Atual
L∞ (Chebyshev)
Iteração (k)
clique para ativar/desativar pixels · arraste para pintar
Elemento Estruturante (b)
Clique para alterar os pesos:
Visualização
Exemplos f
Figura 4.19: Simulador interativo da Transformada de Distância (TD) iterativa via erosão em tons de cinza. Pixels fora da imagem agora assumem o valor máximo (144), propagando os custos apenas a partir do fundo interno.
import numpy as np

# Imagem binária 10×10 com um único pixel de fundo (0,0) e o resto como objeto (255)
f = np.ones((10,10), dtype=np.uint8) * 255
f[0,0] = 0

B_cruz = np.array([
    [-np.inf, -1, -np.inf],
    [-1,      0, -1],
    [-np.inf, -1, -np.inf]
], dtype=float)

d_iter = mm.dist1(f, B_cruz)
d_l2   = mm.dist(f)

print(f"Máx. dist1 (erosões) : {d_iter.max()} px")
print(f"Máx. dist  (L2)      : {d_l2.max():.2f} px")
print(f"Posição do máximo dist1 : {np.where(d_iter == d_iter.max())}")
print(f"Posição do máximo dist  : {np.where(d_l2 == d_l2.max())}")

mm.show(
    [f,        d_iter,                    d_l2],
    titles=["f original", "mm.dist1\n(erosões com cruz)", "mm.dist\n(L2 Euclidiana)"],
    cols=3, figsize=(12, 4), axis=True
)
Máx. dist1 (erosões) : 18 px
Máx. dist  (L2)      : 12.00 px
Posição do máximo dist1 : (array([9]), array([9]))
Posição do máximo dist  : (array([9]), array([9]))
Figura 4.20: Transformada de Distância em imagem binária 10×10. Esquerda: imagem original (foreground = 255). Centro: mm.dist1 iterativa (erosões com elemento cruz). Direita: mm.dist (L2 Euclidiana).

A anotação dos valores numéricos diretamente sobre os pixels permite verificar como dist1 propaga as distâncias segundo a métrica induzida pela função estruturante utilizada. No caso do elemento cruz com custo unitário, os valores obtidos correspondem à distância de Manhattan (\(L_1\)). Embora dist1 e mm.dist produzam valores numéricos distintos por adotarem métricas diferentes, ambas as transformadas preservam a estrutura topográfica dos objetos, fazendo com que seus máximos ocorram em regiões centrais semelhantes. Essa propriedade justifica o uso de mm.dist em aplicações práticas, devido à sua elevada eficiência computacional.

4.4.3 Transformada de Distância Euclidiana em quatro passos

O simulador da Figura 4.21 implementa o algoritmo da TDE de Lotufo; Zampirolli (2001) em duas etapas. Na primeira etapa, a função edt1 realiza uma transformação unidimensional vertical de forma sequencial (in-place), percorrendo cada coluna em raster (↓) e anti-raster (↑) para calcular as distâncias na direção vertical (dois passos: Sul e Norte). Na segunda etapa, a função edt2 utiliza esse resultado como entrada e realiza uma propagação horizontal por filas: para cada linha da matriz, duas filas de prioridade, Eq e Wq, são inicializadas percorrendo os índices de coluna em sentidos opostos (Eq de W-1 até 1, Wq de 2 até W), de modo que cada pixel atualizado enfileira imediatamente seus vizinhos para reprocessamento dentro da mesma rodada (mais dois passos: Leste e Oeste). Esse mecanismo de fila permite aplicar erosões sucessivas com pesos ímpares crescentes (b = 1, 3, 5, ..., incrementado a cada iteração do laço externo) sem que a propagação fique presa em valores desatualizados, já que cada rodada resolve a cadeia de dependências horizontal por completo antes do próximo incremento de b. A convergência dessa propagação produz a Transformada de Distância Euclidiana em toda a matriz, combinando a informação vertical obtida em edt1 com a propagação horizontal em fila realizada em edt2.

EDT² 2D Corrigido · Matriz 4x4
Passo 1: edt1 Vertical In-place · Passo 2: edt2 Horizontal In-place (mesma estrutura raster/anti-raster do edt1)
Passo Atual
Fase
b atual
Figura 4.21: Simulador interativo 2D (4x4) com sincronização estrita de filas de propagação horizontal (b) para obter a convergência exata descrita no artigo.

4.4.3.1 Transformada de Distância Geodésica

A transformada de distância geodésica associa a cada pixel a menor distância até um marcador, sob a restrição imposta por uma máscara. Dessa forma, a propagação ocorre exclusivamente pelos pixels permitidos, preservando a conectividade do domínio.

A Figura 4.22 ilustra esse processo em um labirinto: (a) a máscara g; (b) a distância geodésica D1 calculada a partir da entrada; (c) a distância D2 calculada a partir da saída; e (d) o caminho mínimo obtido a partir dessas duas transformadas.

O caminho ótimo é determinado pela soma das distâncias (D1 + D2). Os pixels pertencentes à trajetória mínima são aqueles para os quais essa soma assume seu menor valor, definindo uma conexão entre entrada e saída com comprimento geodésico mínimo.

Esse princípio permite resolver labirintos sem a necessidade de explorar explicitamente todas as possibilidades de percurso. A solução emerge diretamente da propagação de distâncias em um domínio restrito. Essa abordagem é particularmente relevante em labirintos de elevada complexidade, como os construídos a partir de estruturas quasicristalinas e ciclos hamiltonianos descritos por Singh; Lloyd; Flicker (2024). Em Zampirolli et al. (2025), esse mesmo formalismo é empregado para resolver um labirinto complexo; a seguir, o método é ilustrado em uma versão simplificada do problema.

import numpy as np

# 1 = corredor, 0 = parede
g = np.array([
 [0,1,0,0,0,0,0,0,0,0],
 [0,1,1,1,1,1,0,1,1,1],
 [0,0,0,0,0,1,0,1,0,1],
 [0,1,1,1,0,1,1,1,0,1],
 [0,1,0,1,0,0,0,0,0,1],
 [0,1,0,1,1,1,1,1,1,1],
 [0,1,0,0,0,0,0,0,1,0],
 [0,1,1,1,1,1,1,0,1,0],
 [0,0,0,0,0,0,1,1,1,0],
 [0,0,0,0,0,0,0,0,1,0]
], dtype=np.uint8)

# marcador da entrada
entrada = np.zeros_like(g, dtype=np.uint8)
entrada[0,1] = 1

# marcador da saída
saida = np.zeros_like(g, dtype=np.uint8)
saida[9,8] = 1

# distâncias geodésicas
D1 = mm.gdist(g, entrada)
D2 = mm.gdist(g, saida)

# soma das distâncias
S = D1 + D2

# menor valor válido da soma
dmin = np.min(S[S > 0])

# pixels pertencentes a um caminho ótimo
caminho = (S == dmin)

print("Distância geodésica mínima:", dmin)

mm.show(
    [g, D1, D2, caminho],
    titles=[
        "Labirinto",
        "Distância da Entrada",
        "Distância da Saída",
        f"Menor Caminho\n(d={dmin})"
    ],
    cols=4,
    figsize=(14,4),
    axis=True
)
Distância geodésica mínima: 15
Figura 4.22: Menor caminho geodésico em um labirinto. As distâncias geodésicas são calculadas a partir da entrada e da saída usando mm.gdist. Os pixels cujo somatório das duas distâncias é igual à distância mínima entre os marcadores pertencem a um caminho ótimo.

4.4.4 Segmentação por Watershed

O algoritmo Watershed interpreta uma imagem em níveis de cinza como uma superfície topográfica, na qual valores elevados correspondem a montanhas e valores baixos correspondem a vales ou bacias de drenagem (catchment basins). No contexto da segmentação baseada em marcadores, os máximos da Transformada de Distância são frequentemente utilizados para identificar regiões internas aos objetos, fornecendo sementes confiáveis para o processo de inundação.

A segmentação é então realizada por uma simulação conceitual de inundação progressiva a partir desses marcadores. À medida que as bacias associadas a diferentes sementes se expandem, regiões vizinhas eventualmente entram em contato. Nesse instante são construídas barreiras virtuais, denominadas watershed lines, que passam a delimitar os objetos da cena. Esse mecanismo permite separar objetos adjacentes ou parcialmente sobrepostos, mesmo quando eles formam uma única componente conexa após a limiarização.

A implementação didática apresentada neste capítulo explora inicialmente o conceito de crescimento de regiões (region growing) confinado por uma máscara binária, conforme detalhado no algoritmo interativo da Figura 4.23.

NotaVersão didática versus implementação clássica

A função mm.watershed0 não implementa o algoritmo watershed clássico. Seu objetivo é ilustrar, de forma simplificada, a propagação de marcadores por crescimento de regiões (region growing), permitindo visualizar como diferentes sementes competem pela ocupação do espaço disponível. O crescimento é delimitado por uma máscara binária de suporte e monitorado por um controle de estagnação, gerando um resultado semelhante a uma partição de Voronoi restrita à geometria dos objetos de entrada.

Já a função mm.watershed utiliza a implementação otimizada do OpenCV (cv2.watershed), que realiza a inundação sobre uma superfície topográfica definida pela imagem de entrada. Nesse caso, a propagação dos marcadores é influenciada pelos valores dos pixels, fazendo com que as linhas de separação se formem naturalmente sobre as cristas do relevo.

Algoritmo Didático do Watershed Limitado por Máscara — painel interativo

Passo a passo
Cards
Linha do tempo
Código Python
Fluxograma
Crescimento de Regiões Confinado por Máscara
Inundação concorrente com restrição geométrica de suporte e sincronização síncrona por malha
1
Rotular os marcadores sementes em f via mm.label0(f, b), instanciar a malha dinâmica g ← f.copy() e binarizar a mask.
2
Enquanto houver pixels não rotulados (while True), reiniciar o controle de atividade mudou ← False e varrer a imagem:
Identificar se a coordenada atual é um vazio contido no escopo: g[x,y] == 0 and mask[x,y].
Avaliar a vizinhança estrutural em mm._viz(f, b, x, y) baseada no estado síncrono estável f.
Se um vizinho possuir rótulo dominante (g[x,y] < f[vy,vx]), a célula em g absorve esse identificador e marca-se mudou ← True.
3
Verificar ponto fixo: caso uma varredura completa não expanda nenhuma fronteira (not mudou), interrompe-se o laço (break).
4
Atualizar o estado de referência de forma síncrona para a próxima iteração: f ← g.copy().
5
Se op == 'region', retornar o mapa de bacias g; caso contrário, extrair as cristas divisórias via mm.gradm(g).
A sincronização f = g.copy() ao final de cada ciclo impede o crescimento assimétrico ou dependente da ordem da varredura raster (propagação em estilo Jacobi).
Escopo Geométrico
mask[x,y] > 0
Restrição binária rígida impedindo o avanço periférico de rótulos.
Estabilização
if not mudou: break
Evita loops infinitos interrompendo ao saturar o domínio da máscara.
Mapeamento Jacobi
f = g.copy()
Sincronização em bloco após inspeção de todas as coordenadas.
01
Inicialização
Geração dos identificadores iniciais pelo mapeamento de componentes conexas e binarização da máscara de suporte.
02
Expansão Concorrente
Varredura 2D inspecionando vazios internos autorizados. A malha de trabalho g absorve os rótulos lidos da referência estável f.
03
Ponto Fixo Local
A flag mudou monitora mudanças estruturais. Se nenhuma frente avançar, o laço de inundação é finalizado via break.
04
Sincronização e Saída
Atualização em bloco do estado referencial. A saída pode ser moldada como partições regionais ou linhas de cristas (linhas de watershed).
01
Condicionamento Prévio
Rotulagem preliminar de marcadores e isolamento booleano do domínio.
02
Laço Síncrono Iterativo
while True
Redefinição de flag: mudou = False
Crescimento condicional: se g[x,y] == 0 e estiver na máscara, expande lendo f
Controle de estabilidade: if not mudou: break
Atualização síncrona: f = g.copy()
03
Extração Topológica
Retorno condicional das bacias preenchidas ou cálculo morfológico do gradiente de transição.
mm_watershed.py Algoritmo com Restrição de Máscara
def watershed0(f, mask=None, b=np.zeros((3,3),dtype='uint8'), op='region'):
    f = mm.label0(f, b)
    g = f.copy()
    mask = np.ones_like(f) if mask is None else (mask > 0)

    while True:
        mudou = False
        for x in range(f.shape[0]):
            for y in range(f.shape[1]):
                if g[x,y] == 0 and mask[x,y]:
                    for vy,vx,bv in mm._viz(f, b, x, y):
                        if bv and g[x,y] < f[vy,vx]: 
                            g[x,y] = f[vy,vx]
                            mudou = True
        if not mudou: 
            break
        f = g.copy()

    return g if op == 'region' else mm.gradm(g, mm.secross())
1
mask = (mask > 0) — Converte a imagem de suporte informada para um mapa Booleano indexável.
2
g[x,y] == 0 and mask[x,y] — Filtro ativo: pixels fora da máscara (fundo zero) são ignorados de imediato, confinando as frentes de expansão.
3
if not mudou: break — Mecanismo de escape. Quando todos os espaços internos permitidos forem preenchidos ou estabilizados contra a barreira, o laço aborta de forma limpa.
início rotular sementes e normalizar máscara f ← mm.label0(f, b) ; g ← f.copy() ; mask ← mask > 0 True? reiniciar ciclo: mudou ← False varredura espacial e expansão se g[x,y] == 0 e mask[x,y] e f[vy,vx] > g[x,y]: g[x,y] ← f[vy,vx] ; mudou ← True not mudou? sincronizar malha de ref: f ← g.copy() op == 'region'? retornar g ( bacias ) retornar mm.gradm(g) fim sim não sim (break) não sim não
Figura 4.23: Algoritmo Didático de Watershed por Crescimento de Regiões Limitado por Máscara: passo a passo, cards, linha do tempo, código Python e fluxograma.

A Figura 4.24 apresenta um simulador iterativo que ilustra a propagação dos marcadores pela região de interesse. Cada marcador atua como uma fonte de inundação que expande sua área de influência até encontrar regiões provenientes de outras sementes. No algoritmo do OpenCV, os pixels pertencentes às linhas divisoras são identificados pelo valor -1, representando as fronteiras entre bacias adjacentes.

Simulador: Segmentação por Watershed Propagação com Elemento Estruturante (b)
Área (Máscara)
0
Marcadores
0
Preenchimento
0%
Iteração (k)
arraste para desenhar/apagar a máscara ou as sementes
Ferramentas
Elem. Estruturante (b)
Visualização
Exemplos Iniciais
Figura 4.24: Simulador interativo do Algoritmo Watershed por propagação morfológica. Desenhe a máscara, posicione os marcadores e ajuste o Elemento Estruturante para observar a inundação. Quando as bacias se encontram simultaneamente, o empate é resolvido assumindo uma das regiões de forma aleatória.

Pipeline Morfológico do Watershed

O watershed baseado em marcadores normalmente integra um fluxo mais amplo de segmentação. Em imagens reais, etapas de pré-processamento são frequentemente necessárias para melhorar o contraste, reduzir ruídos e gerar marcadores confiáveis. Esse fluxo completo está resumido na Tabela 4.6.

Tabela 4.6: Pipeline completo do watershed baseado em marcadores para imagens reais.
Etapa Operação Finalidade
1 CLAHE + Suavização Realce de contraste e redução de ruído
2 Limiarização Separação inicial entre objeto e fundo
3 Abertura/Fechamento Remoção de ruídos e pequenas imperfeições
4 Dilatação da Máscara Identificação do Fundo Certo (Sure Background)
5 Transformada de Distância + Limiar Identificação do Objeto Certo (Sure Foreground)
6 Região Incerta Diferença entre Fundo Certo e Objeto Certo
7 cv2.watershed Propagação dos marcadores pela região incerta

Para enfatizar exclusivamente os conceitos de Transformada de Distância, marcadores e inundação topográfica, o exemplo da Figura 4.25 utiliza uma imagem binária sintética e adota um fluxo simplificado, resumido na Tabela 4.7.

Tabela 4.7: Pipeline simplificado utilizado no exemplo didático da Figura 4.25.
Etapa Operação Finalidade
1 Transformada de Distância Construção da superfície topográfica
2 Limiar da TD Extração dos marcadores (Sure Foreground)
3 Dilatação Determinação do Fundo Certo (Sure Background)
4 Região Incerta Diferença entre fundo e marcadores
5 cv2.watershed Propagação dos marcadores e geração das fronteiras
import cv2

# 1. Imagem sintética e Transformada de Distância
f_sint = np.zeros((20, 20), dtype=np.uint8)
cv2.circle(f_sint, (6,  10), 5, 255, -1)
cv2.circle(f_sint, (14, 10), 5, 255, -1)
dist = mm.dist(f_sint)

# 2. Marcadores (picos da distância)
m = (dist > 0.8 * dist.max()).astype(np.uint8) * 255

# 3. Execução do Watershed0 condicional (m=marcadores primeiro, mask=f_sint)
w_reg  = mm.watershed0(m, mask=f_sint, op='region')
w_line = mm.watershed0(m, mask=f_sint, op='line')

#w_reg  = mm.watershedB(m, mask=f_sint, op='region')
#w_line = mm.watershedB(m, mask=f_sint, op='line')

w_reg  = mm.watershed(m, mask=f_sint, op='region')
w_line = mm.watershed(m, mask=f_sint, op='line')

# 4. Exibição dos resultados
mm.show([f_sint, dist, m, w_reg, w_line], cols=5, figsize=(16, 4),
        titles=["Original", "Distância", "Marcadores", "Regiões", "Linhas"])
Figura 4.25: Pipeline watershed delimitado por máscara em imagem binária 20×20.

Aplicação em moedas sobrepostas:

img_base = img_coins_gray

# 1. Simular moedas sobrepostas/conectadas (usando a máscara binária base)
img_sobrepostas = mm.dil(img_final, mm.sebox(40))

# 2. Abertura morfológica para limpar ruídos
opening = mm.open(img_sobrepostas, mm.sebox(2))

# 3. Transformada de Distância
dist = mm.dist(opening)
dist_vis=(255*(dist/dist.max())).astype(np.uint8) if dist.max() > 0 else dist.astype(np.uint8)

# 4. Picos seguros (Marcadores das moedas)
picos = (dist > 0.5 * dist.max()).astype(np.uint8) * 255

# 5. Execução do Watershed (Ajustado para usar a nova assinatura)
# Passamos 'opening' direto em mask, pois ela delimita o escopo de expansão das moedas
ws_region = mm.watershed(picos, mask=opening, op='region')
ws_line   = mm.watershed(picos, mask=opening, op='line')

# 6. Contagem de objetos (Ignora fundo 0)
labels = np.unique(ws_region)
labels = labels[labels > 0]
print(f"Objetos detectados: {len(labels)}")

# 7. Anotação Final
img_annotated = cv2.cvtColor(img_base, cv2.COLOR_GRAY2BGR)

for idx, label_id in enumerate(labels):
    mask_reg = (ws_region == label_id).astype(np.uint8)
    if mask_reg.sum() < 500: continue
    
    cy, cx = np.mean(np.where(mask_reg), axis=1).astype(int)
    cv2.putText(img_annotated, str(idx + 1), (cx - 25, cy + 20),
                cv2.FONT_HERSHEY_SIMPLEX, 3.2, (0, 255, 0), 8, cv2.LINE_AA)

# Desenha as linhas de separação em vermelho
mask_ann = mm.dil(ws_line, np.ones((11, 11), np.uint8))
img_annotated[mask_ann > 0 ] = [255, 0, 0]

# Exibição
mm.show(
    [img_base, img_sobrepostas, dist_vis, picos, ws_region, img_annotated],
    titles=["Original", "Sobrepostas", "Distância", "Marcadores", "Watershed", "Anotado"],
    cols=3, rows=2, figsize=(12, 8)
)
Objetos detectados: 12
Figura 4.26: Pipeline Watershed para separação de moedas sobrepostas: da máscara binária dilatada até os contornos finais anotados sobre a imagem original.

4.5 Extração de Componentes e Descritores de Forma

Após a segmentação e o refinamento morfológico, o próximo passo consiste em identificar individualmente cada objeto presente na imagem e extrair suas propriedades geométricas. Essa etapa é fundamental para tarefas de medição, classificação e reconhecimento de padrões.

Um componente conexo é um conjunto maximal de pixels pertencentes ao objeto que permanecem mutuamente conectados segundo uma relação de conectividade previamente definida (4- ou 8-conectividade). Após a rotulação, cada componente recebe um identificador único, permitindo que suas características sejam analisadas individualmente.

O OpenCV oferece duas abordagens complementares para essa análise, resumidas na Tabela 4.8.

Tabela 4.8: Comparação entre as abordagens baseadas em componentes conexos e em contornos.
connectedComponentsWithStats findContours
Retorna rótulo por pixel e estatísticas por componente sequência de pontos que descreve a borda
Descritores diretos área, bounding box e centróide perímetro, forma e hierarquia
Objetos em contato tende a fundir regiões conectadas tende a produzir um único contorno externo
Uso típico contagem, filtragem e rotulação análise geométrica e descritores de forma

4.5.1 Rotulação e Estatísticas de Componentes

A função cv2.connectedComponentsWithStats realiza simultaneamente a rotulação dos componentes conexos e a extração de descritores básicos de cada região. O operador retorna:

  • uma imagem de rótulos (labels);
  • estatísticas geométricas (stats);
  • coordenadas dos centróides (centroids).

As estatísticas incluem área, largura, altura e posição da bounding box mínima alinhada aos eixos da imagem.

A Figura 4.27 ilustra a aplicação desse operador após o pipeline de segmentação das moedas. Cada componente conexo recebe uma cor distinta e sua área é anotada diretamente sobre a imagem.

# 1. Rotulagem e estatísticas
n, labels, stats, centroids = cv2.connectedComponentsWithStats(
    img_final, connectivity=8
)

# 2. Coloração dos componentes
np.random.seed(4)
colors    = np.random.randint(50, 255, (n, 3), dtype=np.uint8)
colors[0] = [0, 0, 0]                          # fundo preto
img_colored   = colors[labels]
img_annotated = img_colored.copy()

# 3. Tabela de descritores
print(f"Componentes detectados (excluindo fundo): {n - 1}")
print(f"\n{'ID':>4} {'Área':>8} {'cx':>6} {'cy':>6} {'w':>6} {'h':>6}")
print("-" * 42)
for i in range(1, n):
    area = stats[i, cv2.CC_STAT_AREA]
    cx, cy = int(centroids[i, 0]), int(centroids[i, 1])
    w, h   = stats[i, cv2.CC_STAT_WIDTH], stats[i, cv2.CC_STAT_HEIGHT]
    print(f"{i:>4} {area:>8} {cx:>6} {cy:>6} {w:>6} {h:>6}")
    for cor, esp in [((0,0,0), 10), ((255,0,0), 5)]:
        cv2.putText(img_annotated, f"{i}: {area}",
                    (cx - 150, cy + 18),
                    cv2.FONT_HERSHEY_SIMPLEX, 2.0, cor, esp, cv2.LINE_AA)

mm.show(
    [img_coins_gray, img_final, img_colored, img_annotated],
    titles=["Original", "Segmentação Final", "Componentes Conexos", "Áreas Anotadas"],
    cols=4, figsize=(18, 6)
)
Componentes detectados (excluindo fundo): 12

  ID     Área     cx     cy      w      h
------------------------------------------
   1   231969   1484    280    553    542
   2   158967    917    250    453    468
   3   139229    250    312    433    419
   4   175550    857    821    474    465
   5   222882   1442    889    539    531
   6   210043    316    977    527    516
   7   343376    934   1559    667    665
   8   213641   1555   1574    530    515
   9   147880    328   1543    432    438
  10   187280    363   2111    487    492
  11   150110   1487   2215    433    444
  12   215387    932   2292    528    522
Figura 4.27: Componentes conexos extraídos após o pipeline CLAHE → Otsu → limpeza morfológica. Cada objeto é colorido com cor distinta e anotado com sua área em pixels.

4.5.2 Descritores de Forma

Enquanto connectedComponentsWithStats opera sobre regiões, cv2.findContours atua diretamente sobre suas fronteiras. O operador retorna, para cada objeto, uma sequência ordenada de pontos que descreve seu contorno.

A partir dessa representação é possível calcular descritores geométricos que não são fornecidos diretamente pela rotulação de componentes:

  • Área (cv2.contourArea)

  • Perímetro (cv2.arcLength);

  • Circularidade (\(C = \frac{4\pi A}{P^2}\), onde \(A\) é a área e \(P\) o perímetro);

  • Aproximação poligonal (cv2.approxPolyDP);

  • Fecho convexo (convex hull);

  • Hierarquia de contornos, permitindo representar relações pai-filho entre regiões e seus buracos internos.

A circularidade assume valor máximo igual a 1 para um círculo perfeito e diminui à medida que o objeto se torna mais alongado ou apresenta irregularidades em sua borda.

A Figura 4.28 apresenta os contornos extraídos das moedas segmentadas, juntamente com os valores de circularidade calculados para cada objeto.

contornos, hierarquia = cv2.findContours(
    img_final, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
)

img_contornos  = cv2.cvtColor(img_coins_gray, cv2.COLOR_GRAY2BGR)
img_circulares = img_contornos.copy()

print(f"Contornos detectados: {len(contornos)}")
print(f"\n{'ID':>4} {'Área':>8} {'Perímetro':>10} {'Circularidade':>14}")
print("-" * 42)

for i, cnt in enumerate(contornos, start=1):
    area  = cv2.contourArea(cnt)
    perim = cv2.arcLength(cnt, closed=True)
    circ  = (4 * np.pi * area / perim**2) if perim > 0 else 0
    M     = cv2.moments(cnt)
    cx    = int(M["m10"] / M["m00"]) if M["m00"] > 0 else 0
    cy    = int(M["m01"] / M["m00"]) if M["m00"] > 0 else 0

    print(f"{i:>4} {area:>8.0f} {perim:>10.1f} {circ:>14.3f}")

    cv2.drawContours(img_contornos,  [cnt], -1, (0, 255, 0), 3)
    cv2.drawContours(img_circulares, [cnt], -1, (0, 255, 0), 3)
    for cor, esp in [((0,0,0), 8), ((255,0,0), 3)]:
        cv2.putText(img_circulares, f"{circ:.2f}",
                    (cx - 80, cy + 15),
                    cv2.FONT_HERSHEY_SIMPLEX, 3.6, cor, esp, cv2.LINE_AA)

mm.show(
    [img_final, img_contornos, img_circulares],
    titles=["Segmentação Final", "Contornos", "Circularidade"],
    cols=3, figsize=(18, 6)
)
Contornos detectados: 12

  ID     Área  Perímetro  Circularidade
------------------------------------------
   1   214626     1769.6          0.861
   2   149480     1465.1          0.875
   3   186570     1654.9          0.856
   4   147252     1458.6          0.870
   5   212886     1756.3          0.867
   6   342424     2232.5          0.863
   7   209282     1767.7          0.842
   8   222108     1809.7          0.852
   9   174854     1616.4          0.841
  10   138602     1471.5          0.804
  11   158306     1538.7          0.840
  12   231181     1847.4          0.851
Figura 4.28: Contornos extraídos com findContours. Cada moeda é anotada com sua circularidade — valores próximos de 1 confirmam forma circular.

4.5.3 Conexão com Detecção de Objetos Moderna

Os descritores extraídos nas seções anteriores — especialmente bounding boxes, centróides, áreas e medidas de forma — estabelecem uma ponte natural entre a segmentação morfológica clássica e os sistemas modernos de detecção de objetos. Embora as técnicas estudadas neste capítulo utilizem operações sobre pixels e regiões segmentadas, muitas das representações produzidas são diretamente compatíveis com os formatos empregados em modelos contemporâneos de visão computacional.

Detectores baseados em aprendizado profundo, como a família YOLO (You Only Look Once) (Redmon et al., 2016), operam diretamente sobre imagens coloridas e produzem, para cada objeto detectado, uma bounding box descrita pelo centro \((cx,cy)\) e pelas dimensões \((w,h)\), além de uma classe e uma pontuação de confiança. Essa representação compartilha a mesma estrutura geométrica básica obtida por connectedComponentsWithStats, embora seja produzida por um modelo aprendido e não por segmentação explícita.

A Figura 4.29 ilustra como as bounding boxes obtidas por morfologia podem ser exportadas no formato YOLO para compor conjuntos de dados utilizados no treinamento ou na avaliação de detectores.

H_img, W_img = img_coins_gray.shape
img_bbox = cv2.cvtColor(img_coins_gray, cv2.COLOR_GRAY2BGR)

CLASSE = 0   # 0 = moeda (única categoria neste exemplo)

print(f"{'cls':>4} {'cx_n':>8} {'cy_n':>8} {'w_n':>8} {'h_n':>8}  ← formato YOLO")
print("-" * 54)

yolo_linhas = []
for i in range(1, n):
    x0 = stats[i, cv2.CC_STAT_LEFT]
    y0 = stats[i, cv2.CC_STAT_TOP]
    w  = stats[i, cv2.CC_STAT_WIDTH]
    h  = stats[i, cv2.CC_STAT_HEIGHT]

    cx_n = (x0 + w / 2) / W_img
    cy_n = (y0 + h / 2) / H_img
    w_n  = w / W_img
    h_n  = h / H_img

    yolo_linhas.append(f"{CLASSE} {cx_n:.4f} {cy_n:.4f} {w_n:.4f} {h_n:.4f}")
    print(f"{CLASSE:>4} {cx_n:>8.4f} {cy_n:>8.4f} {w_n:>8.4f} {h_n:>8.4f}")

    cv2.rectangle(img_bbox, (x0, y0), (x0 + w, y0 + h), (0, 255, 0), 4)
    cv2.putText(img_bbox, f"moeda", (x0 + 8, y0 + 60),
                cv2.FONT_HERSHEY_SIMPLEX, 3.8, (255, 0, 0), 5, cv2.LINE_AA)

# Exportar arquivo de anotação no formato YOLO
with open("moedas.txt", "w") as f:
    f.write("\n".join(yolo_linhas))
print("\nAnotação salva em moedas.txt")

mm.show(
    [img_coins_gray, img_final, img_bbox],
    titles=["Original", "Segmentação Final", "Bounding Boxes (formato YOLO)"],
    cols=3, figsize=(18, 6)
)
 cls     cx_n     cy_n      w_n      h_n  ← formato YOLO
------------------------------------------------------
   0   0.7753   0.1102   0.2880   0.2117
   0   0.4784   0.0984   0.2359   0.1828
   0   0.1331   0.1225   0.2255   0.1637
   0   0.4464   0.3217   0.2469   0.1816
   0   0.7523   0.3471   0.2807   0.2074
   0   0.1664   0.3812   0.2745   0.2016
   0   0.4862   0.6104   0.3474   0.2598
   0   0.8115   0.6154   0.2760   0.2012
   0   0.1724   0.6023   0.2250   0.1711
   0   0.1893   0.8258   0.2536   0.1922
   0   0.7763   0.8652   0.2255   0.1734
   0   0.4854   0.8953   0.2750   0.2039

Anotação salva em moedas.txt
Figura 4.29: Bounding boxes derivadas dos componentes conexos sobrepostas à imagem original. As anotações são exportadas no formato YOLO (classe cx cy w h), com coordenadas normalizadas para o intervalo [0,1].

O formato YOLO armazena cada objeto em uma linha contendo cinco campos:

\[ \texttt{classe}\;\;\texttt{cx}\;\;\texttt{cy}\;\;\texttt{w}\;\;\texttt{h} \]

onde \((cx,cy)\) representa o centro da bounding box e \((w,h)\) suas dimensões. Todos os valores geométricos são normalizados para o intervalo \([0,1]\) em relação à largura e à altura da imagem. A classe é um identificador inteiro associado a uma categoria definida pelo conjunto de dados (por exemplo, 0 → moeda). Quando há múltiplas categorias — como moeda de ouro (0), moeda de prata (1) e disco plástico (2) — basta atribuir o identificador correspondente a cada objeto antes da exportação, mantendo exatamente o mesmo formato de anotação. No exemplo anterior, essas informações foram armazenadas no arquivo moedas.txt.

O fluxo apresentado neste capítulo — segmentação → rotulação → extração de bounding boxes — corresponde conceitualmente à etapa de anotação (labeling) empregada na construção de conjuntos de treinamento para detectores modernos. Ferramentas especializadas, como Label Studio e Roboflow, automatizam esse processo em imagens complexas, mas a lógica fundamental permanece a mesma: associar a cada objeto uma região de interesse e uma classe. Em cenários controlados, com fundo uniforme e objetos bem separados, técnicas morfológicas podem inclusive gerar anotações automaticamente ou servir como ponto de partida para a rotulação manual, reduzindo significativamente o esforço de construção do conjunto de dados. Em aplicações reais mais complexas, entretanto, a validação humana continua sendo necessária para garantir a qualidade das anotações.

NotaAvaliação: IoU (Intersection over Union)

Uma forma simples de avaliar a qualidade de uma segmentação consiste em compará-la com uma máscara de referência (ground truth). A métrica mais utilizada para essa finalidade é a IoU (Intersection over Union):

\[ \text{IoU}= \frac{|A\cap B|} {|A\cup B|} \tag{4.16}\]

onde \(A\) representa a segmentação produzida pelo algoritmo e \(B\) a segmentação de referência.

O valor da IoU varia entre 0 e 1. Quanto maior o valor, maior a sobreposição entre as máscaras. Uma IoU igual a 1 indica correspondência perfeita entre a segmentação obtida e a referência.

A mesma métrica também é amplamente utilizada em detecção de objetos, sendo aplicada às bounding boxes previstas e anotadas. Nessa área, valores de IoU superiores a 0,5 são frequentemente adotados como critério mínimo para considerar uma detecção correta.

DicaAlém da Morfologia

As técnicas estudadas neste capítulo segmentam objetos explorando conectividade espacial, operações morfológicas e relevo topográfico. Existem, entretanto, abordagens alternativas baseadas em agrupamento de características, como o algoritmo k-means, modelos de mistura Gaussiana (GMM) e métodos mais recentes baseados em aprendizado profundo. Essas técnicas serão retomadas na Parte II do livro, dedicada à Visão Computacional.

4.6 Resumo

Neste capítulo foram apresentadas as principais técnicas de segmentação e morfologia matemática, concluindo o estudo do PDI no domínio espacial.

  • Pré-processamento e limiarização: A combinação entre equalização adaptativa CLAHE e o método de Otsu mostrou-se eficaz para induzir separação bimodal no histograma e simplificar a binarização de imagens com iluminação não uniforme.
  • Erosão e dilatação: Operadores morfológicos fundamentais baseados na busca por mínimos e máximos locais em uma vizinhança definida pelo elemento estruturante \(B\). São operadores duais pelo complemento e foram implementados por meio das funções mm.ero e mm.dil.
  • Abertura e fechamento: Composições de erosão e dilatação que permitem remover ruídos, suavizar contornos e preencher pequenas lacunas, preservando a estrutura global dos objetos.
  • Reconstrução morfológica: Processo geodésico iterativo que propaga um marcador dentro dos limites impostos por uma máscara, constituindo a base de operadores como mm.clohole e mm.edgeoff.
  • Pipeline de limpeza binária: Fluxo consolidado composto por CLAHE → Otsu → abertura → mm.clohole → abertura restrita → mm.edgeoff, produzindo máscaras adequadas para análise quantitativa.
  • Morfologia em tons de cinza: Extensão algébrica baseada em mínimos e máximos ponderados, viabilizando operadores como gradiente morfológico e filtros top-hat para realce de estruturas locais.
  • Transformada de Distância e Watershed: A Transformada de Distância permitiu gerar marcadores automáticos para o algoritmo watershed, possibilitando a separação de objetos adjacentes ou parcialmente sobrepostos.
  • Componentes conexos e descritores: A rotulação de regiões (cv2.connectedComponentsWithStats) e a extração de contornos (cv2.findContours) permitiram calcular descritores geométricos como área, centróide, perímetro, circularidade e bounding boxes.
  • Conexão com Visão Computacional Moderna: As bounding boxes extraídas por morfologia foram exportadas no formato YOLO, evidenciando a ligação entre técnicas clássicas de segmentação e sistemas modernos de detecção de objetos.

O Capítulo 5 introduzirá técnicas de processamento no domínio da frequência, abordando a Transformada de Fourier, filtragem espectral e os fundamentos da compressão de imagens, incluindo DCT, JPEG e wavelets.

4.7 🤖 Uso do Gemini Notebook como Tutor Complementar

Nesta edição, o uso do Gemini Notebook é incentivado como ferramenta complementar de aprendizagem. Baseado em inteligência artificial, o sistema utiliza exclusivamente os documentos fornecidos pelo autor como fonte de conhecimento, produzindo respostas alinhadas ao conteúdo e à abordagem adotada ao longo deste capítulo.

Importante🎓 Estude com o Tutor Inteligente

🚀 ACESSAR Gemini Notebook: CAPÍTULO 04

⚠️ Aviso sobre Conteúdo Gerado por IA

Embora seja uma ferramenta valiosa de apoio aos estudos, o Gemini Notebook pode eventualmente produzir respostas incompletas, imprecisas ou incorretas. Recomenda-se validar as informações consultando o material do capítulo, livros, artigos científicos e outras fontes acadêmicas confiáveis. Sempre que possível, execute e experimente os exemplos práticos apresentados ao longo do texto para consolidar a compreensão dos conceitos.

4.8 Lista de Exercícios

  1. (10%) Implemente manualmente o critério de Otsu sem utilizar cv2.threshold. Calcule a variância interclasses \(\sigma_B^2(T)\) para todos os limiares \(T \in [0,255]\) usando mm.hist, identifique o limiar ótimo \(T^*\) e compare o resultado com o valor obtido pelo OpenCV. Plote \(\sigma_B^2\) em função de \(T\) e destaque o ponto de máximo.

  2. (15%) Aplique limiarização adaptativa com blocos de tamanho 11, 31 e 51 a uma imagem contendo iluminação não uniforme. Compare os resultados com a limiarização global de Otsu e discuta as vantagens e limitações de cada abordagem.

  3. (15%) Execute o pipeline watershed da imagem de moedas variando o limiar aplicado à Transformada de Distância (\(0.3\), \(0.5\) e \(0.7\) vezes o valor máximo). Explique como esse parâmetro influencia a geração dos marcadores, a separação de objetos adjacentes e a ocorrência de sobre-segmentação.

  4. (15%) Utilizando mm.drawImg, construa uma demonstração visual passo a passo da erosão de uma imagem binária 7×7 com elemento estruturante quadrado 3×3. Para cada posição analisada, indique se o elemento estruturante está completamente contido no objeto e justifique o valor atribuído ao pixel de saída.

  5. (15%) Demonstre experimentalmente a dualidade entre erosão e dilatação verificando a identidade \((A \ominus B)^c = A^c \oplus \hat{B}\) utilizando mm.ero, mm.dil e mm.bnot. Calcule a diferença pixel a pixel entre os dois lados da equação e apresente o resultado utilizando mm.histImg ou visualização equivalente.

  6. (15%) Implemente manualmente o gradiente morfológico utilizando apenas mm.ero e mm.dil, comparando o resultado com cv2.morphologyEx(img, cv2.MORPH_GRADIENT, B). Avalie o efeito de diferentes elementos estruturantes (quadrado 3×3, disco 5×5 e linha 1×9) sobre a detecção de bordas.

  7. (15%) Construa um pipeline completo para contagem e classificação de moedas por tamanho (pequena, média e grande) utilizando área e circularidade como descritores. Gere uma máscara de referência (ground truth) manualmente e calcule a métrica IoU (Intersection over Union) para avaliar a qualidade da segmentação. Apresente os resultados em tabela e por meio de visualizações produzidas com mm.show.

Referências do Capítulo

A fundamentação teórica deste capítulo baseia-se nas seguintes obras:

  • Gonzalez; Woods (2018) para os conceitos de segmentação, limiarização de Otsu, Transformada de Distância, watershed, morfologia matemática e descritores de forma.
  • Matheron (1975) e Serra (1982) para a fundamentação teórica original, formulação algébrica e desenvolvimento da Morfologia Matemática.
  • Szeliski (2022) para segmentação baseada em regiões, rotulação de componentes conexos, watershed baseado em marcadores e avaliação de segmentação por meio da métrica IoU.
  • Bradski; Kaehler (2008) para a utilização prática da biblioteca OpenCV, incluindo funções como cv2.distanceTransform, cv2.watershed, cv2.connectedComponentsWithStats e cv2.findContours.
  • Redmon et al. (2016) para a introdução aos detectores modernos da família YOLO e sua relação com descritores geométricos como bounding boxes extraídas por segmentação.
  • Singh; Lloyd; Flicker (2024) para a construção de labirintos complexos baseados em ciclos hamiltonianos sobre mosaicos quasicristalinos, utilizados como exemplo de aplicação da Transformada de Distância Geodésica e de algoritmos de busca de caminhos.
  • Zampirolli et al. (2025) para a implementação dos operadores morfológicos, transformadas geodésicas e resolução de labirintos por propagação de distâncias em domínios restritos.

4.9 💻 Parte Prática com Exercícios de Programação

Esta lista transforma os conceitos do Capítulo 4 em uma trilha prática de segmentação e morfologia matemática. Os EPs começam com limiarização e avançam até rotulação e descritores de componentes, sempre com matrizes pequenas para que cada pixel possa ser conferido à mão.

ImportanteRegra comum dos EPs morfológicos

Nas operações com vizinhança, não faça padding. Para cada pixel, avalie apenas as posições do elemento estruturante que caem dentro do domínio da imagem. Essa é a mesma ideia das implementações didáticas em morph.py, como mm.dil0, mm.ero0, mm.dil1 e mm.label0: a vizinhança é recortada pelo domínio válido da imagem.

🎯 Objetivo deste Caderno

O caderno permite desenvolver, validar, organizar e testar soluções de Exercícios de Programação (EPs) em ambientes interativos, como o Colab, com os mesmos casos de teste do Moodle, copiando para lá apenas na hora de registrar a nota oficial.

Download

Baixe morph.py e testsuite.py executando a célula abaixo:

import os, sys, importlib, inspect, urllib.request

# URLs do repositório
BASE_URL = "https://raw.githubusercontent.com/fzampirolli/pdi-vc/master/morph"
for f in ["morph.py", "testsuite.py"]:
    if not os.path.exists(f):
        urllib.request.urlretrieve(f"{BASE_URL}/{f}", f)

import morph, testsuite
importlib.reload(morph); importlib.reload(testsuite)
from morph import mm
from testsuite import TestSuite

print(f"✅ Ambiente pronto. Morph: {morph.__version__} | TestSuite: {testsuite.__version__}")
✅ Ambiente pronto. Morph: 1.1.2 | TestSuite: 1.1.2

Executando os Testes

Para avaliar os testes, execute TestSuite("EP04_01.extensão").run() numa nova célula, trocando a extensão pela da linguagem usada (.py, .java, .c, .cpp, .js ou .r). O sistema baixa os casos de teste do GitHub, executa o programa e calcula a nota automaticamente.

Para testar código Python diretamente, sem salvar arquivo, use run_code(codigo) passando o código como string numa variável codigo:

codigo = """
from morph import mm
# ... seu código aqui ...
"""
TestSuite("EP04_01").run_code(codigo)

4.9.1 EP04_01 🎚️ Limiarização Global por Limiar Fixo

Em scanners de documentos e em sistemas de leitura de código de barras, a primeira etapa do processamento é sempre separar o que é “objeto” (tinta, texto, barras) do que é “fundo” (papel, embalagem). A limiarização global faz exatamente isso: compara cada pixel a um único limiar \(T\) e decide, em tempo real, se ele pertence à classe clara ou à classe escura. É o operador de segmentação mais simples — e mesmo assim, está por trás de boa parte dos pipelines industriais de inspeção visual. Ver na Figura 4.30 uma simulação deste EP.

4.9.1.1 📋 Diretrizes de Implementação

  1. Dimensões: Ler os inteiros \(L\) (linhas) e \(C\) (colunas).
  2. Limiar: Ler o inteiro \(T\) (limiar de decisão).
  3. Dados: Ler os valores inteiros da matriz original linha a linha.
  4. Mapeamento: Para cada pixel \(p\), calcular o novo valor pela equação:

\[ p' = \begin{cases} 255, & \text{se } p > T \\ 0, & \text{se } p \le T \end{cases} \] 5. Saída: Exibir a matriz binarizada com dimensões \(L \times C\).

4.9.1.2 📌 Restrições Computacionais

  • Binarização: A saída contém apenas os valores \(0\) ou \(255\).
  • Comparação estrita: O critério usa \(> T\) (pixels iguais a \(T\) tornam-se fundo).
  • Tipo: O resultado final deve ser inteiro.
  • Observação: Este EP segue a convenção da OpenCV (cv2.THRESL_BINARY): apenas pixels com valor maior que \(T\) tornam-se brancos (255); pixels com valor igual a \(T\) permanecem pretos (0).

4.9.1.3 🧠 Fundamentação Teórica

Parâmetro Tipo Impacto Visual
\(T\) pequeno Inteiro A maioria dos pixels torna-se branca
\(T\) grande Inteiro A maioria dos pixels torna-se preta
\(T\) bem escolhido Inteiro Separa nitidamente objeto e fundo

4.9.1.4 📦 Especificação de Entrada e Saída (VPL)

Entrada:

  • Linha 1: Inteiro \(L\).
  • Linha 2: Inteiro \(C\).
  • Linha 3: Inteiro \(T\).
  • Linhas seguintes: Elementos inteiros da matriz original.

Saída:

  • Matriz binarizada em \(L\) linhas e \(C\) colunas, valores \(0\) ou \(255\) separados por espaço.

4.9.1.5 📌 Exemplos

Entrada Saída Observação
2
4
100
0 99 100 180
255 30 120 80
0 0 0 255
255 0 255 0
\(T=100\): apenas pixels com valor maior que 100 tornam-se brancos;
por isso, 99 e 100 tornam-se pretos.
1
3
0
0 50 255
0 255 255 \(T=0\): apenas pixels com valor estritamente maior que 0 tornam-se brancos.
🎮 Simulador: Limiarização Global 🎚️ p' = (p > T) ? 255 : 0

👆 Clique numa célula de Entrada Original para escurecer o pixel (e clique direito para clarear)

128

Entrada Original (clicável)

Resultado (p')

Figura 4.30: Simulador: Limiarização Global por Limiar Fixo
%%writefile EP04_01.py
# Código Python
Writing EP04_01.py
TestSuite("EP04_01.py").run()
✔️ EP04_01.cases já existe em casos/
📋 7 caso(s) carregado(s) de casos/EP04_01.cases

🔍 Testando Python: EP04_01.py
⚠️ EP04_01.py: Arquivo sem conteúdo (menos de 3 linhas). Testes ignorados.

4.9.2 EP04_02 📊 Limiarização Automática de Otsu

Escolher manualmente o limiar \(T\) funciona quando a iluminação é estável, mas em microscopia digital e em inspeção de lâminas de sangue, cada amostra tem um contraste diferente — um limiar fixo falharia de imagem para imagem. O método de Otsu resolve isso encontrando, sozinho, o limiar que maximiza a separação estatística entre as duas classes de pixels, tornando a segmentação automática e adaptativa. Ver na Figura 4.31 uma simulação deste EP.

4.9.2.1 📋 Diretrizes de Implementação

  1. Dimensões: Ler os inteiros \(L\) (linhas) e \(C\) (colunas).

  2. Dados: Ler os valores inteiros da matriz original linha a linha.

  3. Histograma: Construir o histograma \(h[i]\), \(i=0,\dots,255\), contando quantos pixels têm valor \(i\).

  4. Busca do limiar: Para cada candidato \(T\) de \(1\) a \(255\), calcular a variância entre classes: \[ \sigma_B^2(T) = \frac{n_0 \cdot n_1}{N^2}\,(m_0 - m_1)^2 \] onde \(n_0,n_1\) são as quantidades de pixels com valor \(<T\) e \(\geq T\), \(m_0,m_1\) são suas médias, e \(N=L\times C\).

  5. Escolha: O limiar ótimo \(T^*\) é o que maximiza \(\sigma_B^2(T)\) (em caso de empate, manter o primeiro encontrado).

  6. Aplicação: Binarizar a imagem usando T*, aplicando: \[ p' = \begin{cases} 255, & \text{se } p > T^* \\ 0, & \text{se } p \le T^* \end{cases} \]

4.9.2.2 📌 Restrições Computacionais

  • Candidatos válidos: Ignorar \(T\) que deixe \(n_0=0\) ou \(n_1=0\) (classe vazia).
  • Empate: Sempre manter o primeiro \(T\) que atingiu o valor máximo de \(\sigma_B^2\).
  • Tipo: \(T^*\) e a matriz de saída devem ser inteiros.
  • Convenção OpenCV: A binarização segue cv2.THRESL_BINARY; pixels com valor exatamente igual a \(T^*\) tornam-se pretos.

4.9.2.3 🧠 Fundamentação Teórica

Conceito Significado Impacto
\(\sigma_B^2(T)\) alta Classes bem separadas em \(T\) \(T\) é um bom candidato a limiar
Histograma bimodal Dois “morros” distintos Otsu encontra o vale entre eles
Histograma unimodal Um único “morro” Otsu ainda escolhe algum \(T\), mas a segmentação é pouco confiável

4.9.2.4 📦 Especificação de Entrada e Saída (VPL)

Entrada:

  • Linha 1: Inteiro \(L\).
  • Linha 2: Inteiro \(C\).
  • Linhas seguintes: Elementos inteiros da matriz original.

Saída:

  • Matriz binarizada em \(L\) linhas e \(C\) colunas, valores \(0\) ou \(255\).

4.9.2.5 📌 Exemplos

Entrada Saída Observação
4
4
12 12 12 200
12 12 200 200
12 200 200 200
200 200 200 200
0 0 0 255
0 0 255 255
0 255 255 255
255 255 255 255
Histograma bimodal claro: 12 e 200
1
2
10 250
0 250 Apenas dois valores: \(T^*\) fica no maior
🎮 Simulador: Otsu Automático 📊 T* = argmax σ²ᴮ(T)

👆 Clique esquerdo escurece, clique direito clarece o pixel — veja o T* mudar sozinho

T* = -

Entrada Original (clicável)

Resultado (Otsu)

Figura 4.31: Simulador: Limiarização Automática de Otsu
%%writefile EP04_02.py
# Código Python
Writing EP04_02.py
TestSuite("EP04_02.py").run()
✔️ EP04_02.cases já existe em casos/
📋 5 caso(s) carregado(s) de casos/EP04_02.cases

🔍 Testando Python: EP04_02.py
⚠️ EP04_02.py: Arquivo sem conteúdo (menos de 3 linhas). Testes ignorados.

4.9.3 EP04_03 🌱 Dilatação Binária Plana (mm.dil0)

Em microscopia de partículas e em OCR de placas de carro desgastadas, traços finos ou descontínuos precisam ser “engrossados” para que o reconhecimento funcione. A dilatação morfológica faz exatamente isso: expande regiões claras usando um elemento estruturante \(B\) — a mesma operação implementada em morph.py como mm.dil0(f, B), usada quando \(B\) é plano (sem pesos, só \(0\)/\(1\)). Ver na Figura 4.32 uma simulação deste EP.

4.9.3.1 📋 Diretrizes de Implementação

  1. Dimensões da imagem: Ler os inteiros \(L\) (linhas) e \(C\) (colunas) de \(f\).
  2. Dimensões de \(B\): Ler os inteiros \(L_B\) (linhas) e \(C_B\) (colunas) do elemento estruturante.
  3. Elemento estruturante: Ler a matriz \(B\) com valores \(0\) ou \(1\), linha a linha.
  4. Dados: Ler a matriz \(f\) (a imagem original), linha a linha.
  5. Reflexão: Construir \(B_{ref}\), a versão de \(B\) refletida em \(180°\) (linhas e colunas invertidas) — exatamente como faz mm.dil0 internamente.
  6. Vizinhança sem padding: Para cada pixel \((y,x)\), percorrer as posições \((by,bx)\) de \(B_{ref}\) centradas em \((y,x)\), usando o deslocamento \[ v_y = y + by + o_y,\quad v_x = x + bx + o_x,\quad o_y=-\tfrac{L_B}{2}+0{,}5,\quad o_x=-\tfrac{C_B}{2}+0{,}5 \] Descartar todo \((v_y,v_x)\) fora de \([0,L)\times[0,C)\)não preencher com zeros.
  7. Mapeamento: Calcular cada pixel de saída como o máximo entre \(f(y,x)\) e todos os \(f(v_y,v_x)\) válidos cuja posição correspondente em \(B_{ref}\) vale \(1\): \[ g(y,x) = \max\Big(f(y,x),\ \max_{\substack{(v_y,v_x)\ \text{válido}\\ B_{ref}(by,bx)=1}} f(v_y,v_x)\Big) \]
  8. Saída: Exibir a matriz \(g\) com dimensões \(L \times C\).

4.9.3.2 📌 Restrições Computacionais

  • Sem padding: Jamais inventar vizinhos fora da imagem; usar apenas os que existem de fato.
  • Reflexão obrigatória: \(B\) deve ser refletido antes de aplicado (é o que diferencia mm.dil0 de uma simples busca por máximo).
  • Robustez de borda: Se nenhuma posição válida de \(B_{ref}=1\) cair dentro do domínio para um dado pixel, ele mantém seu valor original.

4.9.3.3 🧠 Fundamentação Teórica

Conceito Significado Impacto Visual
Dilatação \(g \geq f\) sempre (extensiva) Regiões claras crescem, buracos escuros encolhem
\(B\) maior Vizinhança mais ampla Crescimento mais agressivo
Reflexão de \(B\) \(B_{ref}(y,x) = B(-y,-x)\) Garante a definição formal de Minkowski da dilatação

4.9.3.4 📦 Especificação de Entrada e Saída (VPL)

Entrada:

  • Linha 1: Inteiro \(L\).
  • Linha 2: Inteiro \(C\).
  • Linha 3: Inteiro \(L_B\).
  • Linha 4: Inteiro \(C_B\).
  • Próximas \(L_B\) linhas: elementos inteiros (\(0\) ou \(1\)) da matriz \(B\).
  • Próximas \(L\) linhas: elementos inteiros da matriz \(f\).

Saída:

  • Matriz \(g\) em \(L\) linhas e \(C\) colunas, valores inteiros separados por espaço.

4.9.3.5 📌 Exemplos

Entrada Saída Observação
3
3
3
3
0 1 0
1 1 1
0 1 0
0 0 0
0 9 0
0 0 0
0 9 0
9 9 9
0 9 0
\(B\) em cruz simétrico: ponto isolado se expande em cruz
1
4
1
3
1 1 1
10 200 5 80
200 200 200 80 \(B\) horizontal: cada pixel “puxa” o máximo dos vizinhos da linha
🎮 Simulador: Dilatação Plana (mm.dil0) 🌱 g = f ⊕ B

Elemento Estruturante B (clique para alternar 0/1)

👆 Clique nas células de f para acender/apagar pixels

Imagem Original (f)

Dilatada (g)

g(y,x) = max sobre vizinhos válidos de B refletido
Figura 4.32: Simulador: Dilatação Binária Plana (mm.dil0)
%%writefile EP04_03.py
# Código Python
Writing EP04_03.py
TestSuite("EP04_03.py").run()
✔️ EP04_03.cases já existe em casos/
📋 5 caso(s) carregado(s) de casos/EP04_03.cases

🔍 Testando Python: EP04_03.py
⚠️ EP04_03.py: Arquivo sem conteúdo (menos de 3 linhas). Testes ignorados.

4.9.4 EP04_04 🪨 Erosão Binária Plana (mm.ero0)

Se a dilatação engrossa, a erosão afina. Em sistemas de contagem de células, ela é usada para separar células que se tocam: ao “comer” as bordas de cada região, conexões finas entre objetos desaparecem antes mesmo de qualquer contagem ser feita. Em morph.py, essa é a operação mm.ero0(f, B) — a dual exata da dilatação, e a única das duas que não reflete o elemento estruturante. Ver na Figura 4.33 uma simulação deste EP.

4.9.4.1 📋 Diretrizes de Implementação

  1. Dimensões da imagem: Ler os inteiros \(L\) (linhas) e \(C\) (colunas) de \(f\).
  2. Dimensões de \(B\): Ler os inteiros \(L_B\) (linhas) e \(C_B\) (colunas) do elemento estruturante.
  3. Elemento estruturante: Ler a matriz \(B\) com valores \(0\) ou \(1\), linha a linha.
  4. Dados: Ler a matriz \(f\) (a imagem original), linha a linha.
  5. Vizinhança sem padding (sem reflexão!): Para cada pixel \((y,x)\), percorrer as posições \((by,bx)\) de \(B\) na ordem original (sem refletir), usando o mesmo deslocamento do EP04_03: \[ v_y = y + by + o_y,\quad v_x = x + bx + o_x,\quad o_y=-\tfrac{L_B}{2}+0{,}5,\quad o_x=-\tfrac{C_B}{2}+0{,}5 \]

Descartar todo \((v_y,v_x)\) fora de \([0,L)\times[0,C)\). 6. Mapeamento: Calcular cada pixel de saída como o mínimo entre \(f(y,x)\) e todos os \(f(v_y,v_x)\) válidos cuja posição correspondente em \(B\) vale \(1\): \[ g(y,x) = \min\Big(f(y,x),\ \min_{\substack{(v_y,v_x)\ \text{válido}\\ B(by,bx)=1}} f(v_y,v_x)\Big) \] 7. Saída: Exibir a matriz \(g\) com dimensões \(L \times C\).

4.9.4.2 📌 Restrições Computacionais

  • Sem reflexão: Diferente da dilatação, \(B\) é usado exatamente como lido — refletir aqui seria um erro conceitual grave.
  • Sem padding: Vizinhos fora da imagem são simplesmente ignorados, nunca tratados como \(0\).
  • Robustez de borda: Se nenhuma posição válida de \(B=1\) cair dentro do domínio, o pixel mantém seu valor original.

4.9.4.3 🧠 Fundamentação Teórica

Conceito Significado Impacto Visual
Erosão \(g \leq f\) sempre (anti-extensiva) Regiões claras encolhem, ruído pontual desaparece
Dualidade \(\text{ero}(f,B) = -\text{dil}(-f, B_{ref})\) Erosão e dilatação são “espelhos” matemáticos
\(B\) maior Erosão mais agressiva Objetos finos somem completamente

4.9.4.4 📦 Especificação de Entrada e Saída (VPL)

Entrada:

  • Linha 1: Inteiro \(L\).
  • Linha 2: Inteiro \(C\).
  • Linha 3: Inteiro \(L_B\).
  • Linha 4: Inteiro \(C_B\).
  • Próximas \(L_B\) linhas: elementos inteiros (\(0\) ou \(1\)) da matriz \(B\).
  • Próximas \(L\) linhas: elementos inteiros da matriz \(f\).

Saída:

  • Matriz \(g\) em \(L\) linhas e \(C\) colunas, valores inteiros separados por espaço.

4.9.4.5 📌 Exemplos

Entrada Saída Observação
3
3
3
3
0 1 0
1 1 1
0 1 0
9 9 9
9 0 9
9 9 9
9 0 9
0 0 0
9 0 9
O “buraco” central (0) se propaga em cruz
1
4
1
3
1 1 1
10 200 5 80
10 5 5 80 \(B\) horizontal: cada pixel “puxa” o mínimo dos vizinhos da linha
🎮 Simulador: Erosão Plana (mm.ero0) 🪨 g = f ⊖ B

Elemento Estruturante B (clique para alternar 0/1)

👆 Clique nas células de f para acender/apagar pixels

Imagem Original (f)

Erodida (g)

g(y,x) = min sobre vizinhos válidos de B (sem refletir)
Figura 4.33: Simulador: Erosão Binária Plana (mm.ero0)
%%writefile EP04_04.py
# Código Python
Writing EP04_04.py
TestSuite("EP04_04.py").run()
✔️ EP04_04.cases já existe em casos/
📋 5 caso(s) carregado(s) de casos/EP04_04.cases

🔍 Testando Python: EP04_04.py
⚠️ EP04_04.py: Arquivo sem conteúdo (menos de 3 linhas). Testes ignorados.

4.9.5 EP04_05 🧹 Abertura Morfológica (Remoção de Ruído)

Imagens capturadas por sensores de baixo custo, como os de drones agrícolas, costumam vir salpicadas de pequenos pontos de ruído — pixels isolados que não representam nada real. Aplicar erosão seguida de dilatação com o mesmo elemento estruturante produz a abertura: ela “limpa” pontos e protuberâncias finas, mas devolve ao objeto principal praticamente seu tamanho original. É a combinação clássica usada em pré-processamento de imagens de satélite antes de qualquer contagem de área plantada. Ver na Figura 4.34 uma simulação deste EP.

4.9.5.1 📋 Diretrizes de Implementação

  1. Dimensões da imagem: Ler os inteiros \(L\) (linhas) e \(C\) (colunas) de \(f\).
  2. Dimensões de \(B\): Ler os inteiros \(L_B\) (linhas) e \(C_B\) (colunas) do elemento estruturante.
  3. Elemento estruturante: Ler a matriz \(B\) com valores \(0\) ou \(1\), linha a linha.
  4. Dados: Ler a matriz binária \(f\) (valores \(0\) ou \(1\)), linha a linha.
  5. Erosão: Calcular \(e = f \ominus B\), usando exatamente o algoritmo do EP04_04 (sem refletir \(B\), sem padding).
  6. Dilatação: Calcular \(g = e \oplus B\), usando exatamente o algoritmo do EP04_03 (refletindo \(B\), sem padding) — mas agora aplicado sobre \(e\), não sobre \(f\).
  7. Saída: Exibir a matriz resultante \(g\) (a abertura de \(f\) por \(B\)) com dimensões \(L \times C\).

4.9.5.2 📌 Restrições Computacionais

  • Ordem fixa: É sempre erosão primeiro, depois dilatação — a ordem inversa define outro operador (fechamento, do próximo EP).
  • Mesmo \(B\): O elemento estruturante usado na erosão e na dilatação deve ser idêntico.
  • Sem padding em nenhuma das duas etapas.

4.9.5.3 🧠 Fundamentação Teórica

Conceito Significado Impacto Visual
Anti-extensividade \(g \subseteq f\) sempre A abertura nunca cria pixel novo, só remove
Idempotência \(\text{abertura}(\text{abertura}(f)) = \text{abertura}(f)\) Aplicar de novo não muda mais nada
Pontos isolados Menores que \(B\) São completamente eliminados
Núcleo do objeto Maior que \(B\) É recuperado quase intacto pela dilatação final

4.9.5.4 📦 Especificação de Entrada e Saída (VPL)

Entrada:

  • Linha 1: Inteiro \(L\).
  • Linha 2: Inteiro \(C\).
  • Linha 3: Inteiro \(L_B\).
  • Linha 4: Inteiro \(C_B\).
  • Próximas \(L_B\) linhas: elementos inteiros (\(0\) ou \(1\)) da matriz \(B\).
  • Próximas \(L\) linhas: elementos inteiros (\(0\) ou \(1\)) da matriz \(f\).

Saída:

  • Matriz resultante em \(L\) linhas e \(C\) colunas, valores \(0\) ou \(1\).

4.9.5.5 📌 Exemplos

Entrada Saída Observação
7
7
3
3
1 1 1
1 1 1
1 1 1
0 0 0 0 0 0 0
0 1 0 0 0 1 0
0 0 1 1 1 0 0
0 0 1 1 1 0 0
0 0 1 1 1 1 0
0 0 0 0 0 0 0
0 1 0 0 0 0 1
0 0 0 0 0 0 0
0 0 0 0 0 0 0
0 0 1 1 1 0 0
0 0 1 1 1 0 0
0 0 1 1 1 0 0
0 0 0 0 0 0 0
0 0 0 0 0 0 0
Pontos isolados e a protuberância fina desaparecem; o quadrado central sobrevive
🎮 Simulador: Abertura Morfológica 🧹 g = (f ⊖ B) ⊕ B

👆 Clique nas células de f original para acender/apagar pixels (crie seu próprio ruído!)


3×3

f original (clicável)

e = f ⊖ B

g = e ⊕ B

Figura 4.34: Simulador: Abertura Morfológica (erosão + dilatação)
%%writefile EP04_05.py
# Código Python
Writing EP04_05.py
TestSuite("EP04_05.py").run()
✔️ EP04_05.cases já existe em casos/
📋 5 caso(s) carregado(s) de casos/EP04_05.cases

🔍 Testando Python: EP04_05.py
⚠️ EP04_05.py: Arquivo sem conteúdo (menos de 3 linhas). Testes ignorados.

4.9.6 EP04_06 🧩 Fechamento Morfológico (Preenchimento de Falhas)

Em digitalização de impressões digitais, sulcos da pele às vezes ficam interrompidos por sujeira ou ressecamento, criando pequenas falhas na curva contínua que deveria existir. O fechamento — dilatação seguida de erosão com o mesmo elemento estruturante — é o operador dual da abertura: ele preenche buracos pequenos e reentrâncias estreitas, sem alterar significativamente o contorno externo do objeto. É a etapa padrão antes de extrair o esqueleto de uma impressão digital. Ver na Figura 4.35 uma simulação deste EP.

4.9.6.1 📋 Diretrizes de Implementação

  1. Dimensões da imagem: Ler os inteiros \(L\) (linhas) e \(C\) (colunas) de \(f\).
  2. Dimensões de \(B\): Ler os inteiros \(L_B\) (linhas) e \(C_B\) (colunas) do elemento estruturante.
  3. Elemento estruturante: Ler a matriz \(B\) com valores \(0\) ou \(1\), linha a linha.
  4. Dados: Ler a matriz binária \(f\) (valores \(0\) ou \(1\)), linha a linha.
  5. Dilatação: Calcular \(d = f \oplus B\), usando exatamente o algoritmo do EP04_03 (refletindo \(B\), sem padding).
  6. Erosão: Calcular \(g = d \ominus B\), usando exatamente o algoritmo do EP04_04 (sem refletir \(B\), sem padding) — agora aplicado sobre \(d\), não sobre \(f\).
  7. Saída: Exibir a matriz resultante \(g\) (o fechamento de \(f\) por \(B\)) com dimensões \(L \times C\).

4.9.6.2 📌 Restrições Computacionais

  • Ordem fixa: É sempre dilatação primeiro, depois erosão — a ordem inversa é a abertura do EP04_05.
  • Mesmo \(B\): O elemento estruturante usado na dilatação e na erosão deve ser idêntico.
  • Sem padding em nenhuma das duas etapas.

4.9.6.3 🧠 Fundamentação Teórica

Conceito Significado Impacto Visual
Extensividade \(g \supseteq f\) sempre O fechamento nunca remove pixel, só adiciona
Idempotência \(\text{fecha}(\text{fecha}(f)) = \text{fecha}(f)\) Aplicar de novo não muda mais nada
Buracos pequenos Menores que \(B\) São completamente preenchidos
Dualidade \(\text{fecha}(f) = \overline{\text{abre}(\bar f)}\) É a abertura aplicada ao “negativo” da imagem

4.9.6.4 📦 Especificação de Entrada e Saída (VPL)

Entrada:

  • Linha 1: Inteiro \(L\).
  • Linha 2: Inteiro \(C\).
  • Linha 3: Inteiro \(L_B\).
  • Linha 4: Inteiro \(C_B\).
  • Próximas \(L_B\) linhas: elementos inteiros (\(0\) ou \(1\)) da matriz \(B\).
  • Próximas \(L\) linhas: elementos inteiros (\(0\) ou \(1\)) da matriz \(f\).

Saída:

  • Matriz resultante em \(L\) linhas e \(C\) colunas, valores \(0\) ou \(1\).

4.9.6.5 📌 Exemplos

Entrada Saída Observação
8
8
3
3
1 1 1
1 1 1
1 1 1
0 0 0 0 0 0 0 0
0 0 1 1 1 1 0 0
0 0 1 1 1 1 0 0
0 0 1 0 1 1 0 0
0 0 1 1 0 1 0 0
0 0 1 1 1 1 0 0
0 0 1 1 1 1 0 0
0 0 0 0 0 0 0 0
0 0 1 1 1 1 0 0
0 0 1 1 1 1 0 0
0 0 1 1 1 1 0 0
0 0 1 1 1 1 0 0
0 0 1 1 1 1 0 0
0 0 1 1 1 1 0 0
0 0 1 1 1 1 0 0
0 0 1 1 1 1 0 0
Os dois buracos internos não-adjacentes são totalmente preenchidos
🎮 Simulador: Fechamento Morfológico 🧩 g = (f ⊕ B) ⊖ B

👆 Clique nas células de f original para acender/apagar pixels


3×3

f original (clicável)

d = f ⊕ B

g = d ⊖ B

Figura 4.35: Simulador: Fechamento Morfológico (dilatação + erosão)
%%writefile EP04_06.py
# Código Python
Writing EP04_06.py
TestSuite("EP04_06.py").run()
✔️ EP04_06.cases já existe em casos/
📋 5 caso(s) carregado(s) de casos/EP04_06.cases

🔍 Testando Python: EP04_06.py
⚠️ EP04_06.py: Arquivo sem conteúdo (menos de 3 linhas). Testes ignorados.

4.9.7 EP04_07 ⛰️ Dilatação e Erosão com Pesos (mm.dil1 / mm.ero1)

Até agora, o elemento estruturante só dizia “este vizinho conta” ou “não conta” — mas em modelos digitais de elevação (usados em SIG e em planejamento de drenagem urbana), cada vizinho deveria ter um peso diferente dependendo da distância ou da direção do relevo. As versões ponderadas da dilatação e da erosão, implementadas em morph.py como mm.dil1(f, b) e mm.ero1(f, b), somam (ou subtraem) o peso de cada vizinho antes de tomar o máximo (ou mínimo) — generalizando tudo o que foi feito nos EPs anteriores. Ver na Figura 4.36 uma simulação deste EP.

4.9.7.1 📋 Diretrizes de Implementação

  1. Dimensões da imagem: Ler os inteiros \(L\) (linhas) e \(C\) (colunas) de \(f\).
  2. Dimensões de \(b\): Ler os inteiros \(L_B\) (linhas) e \(C_B\) (colunas) do elemento estruturante ponderado.
  3. Pesos: Ler a matriz \(b\) de pesos inteiros (podem ser negativos, zero ou positivos), linha a linha.
  4. Dados: Ler a matriz \(f\) (a imagem original), linha a linha.
  5. Vizinhança sem padding: Para cada pixel \((y,x)\), percorrer todas as posições \((by,bx)\) de \(b\) (não apenas onde valeria \(1\) — aqui todo peso participa), usando o mesmo deslocamento dos EPs anteriores: \[ v_y = y + by + o_y,\quad v_x = x + bx + o_x,\quad o_y=-\tfrac{L_B}{2}+0{,}5,\quad o_x=-\tfrac{C_B}{2}+0{,}5 \] Descartar todo \((v_y,v_x)\) fora de \([0,L)\times[0,C)\).
  6. Dilatação ponderada: Calcular \[ g_{dil}(y,x) = \max\Big(f(y,x),\ \max_{(v_y,v_x)\ \text{válido}} \big(f(v_y,v_x) + b(by,bx)\big)\Big) \]
  7. Erosão ponderada: Calcular, usando o mesmo \(b\) e sem refletir: \[ g_{ero}(y,x) = \min\Big(f(y,x),\ \min_{(v_y,v_x)\ \text{válido}} \big(f(v_y,v_x) - b(by,bx)\big)\Big) \]
  8. Saída: Exibir primeiro a matriz \(g_{dil}\) completa, e depois a matriz \(g_{ero}\) completa.

4.9.7.2 📌 Restrições Computacionais

  • Nenhuma das duas reflete \(b\) — a versão ponderada não usa reflexão, mesmo na dilatação (diferente de mm.dil0).
  • Todos os pesos participam: Não existe aqui o filtro “\(B=1\)”; mesmo peso \(0\) entra na conta.
  • Sem padding: vizinhos fora da imagem são ignorados, nunca virtualmente preenchidos.
  • Tipo: A saída pode conter valores negativos ou maiores que \(255\)nãoclipping neste EP.
  • Dica: Para remover mensagens de overflow ao ultrapassar limites do tipo uint8, incluir no início do código:
import warnings
warnings.filterwarnings("ignore")

4.9.7.3 🧠 Fundamentação Teórica

Conceito Significado Impacto Visual
Peso positivo “Puxa” o valor do vizinho para cima na dilatação Simula relevo que sobe naquela direção
Peso negativo Reduz a contribuição do vizinho Simula distância ou atenuação direcional
Dualidade ponderada \(\text{ero1}(f,b) = -\text{dil1}(-f,b)\) A simetria entre as duas operações se mantém mesmo com pesos

4.9.7.4 📦 Especificação de Entrada e Saída (VPL)

Entrada:

  • Linha 1: Inteiro \(L\).
  • Linha 2: Inteiro \(C\).
  • Linha 3: Inteiro \(L_B\).
  • Linha 4: Inteiro \(C_B\).
  • Próximas \(L_B\) linhas: elementos inteiros (podem ser negativos) da matriz \(b\).
  • Próximas \(L\) linhas: elementos inteiros da matriz \(f\).

Saída:

  • Primeiro a matriz \(g_{dil}\) em \(L\) linhas e \(C\) colunas.
  • Em seguida a matriz \(g_{ero}\) em \(L\) linhas e \(C\) colunas.

4.9.7.5 📌 Exemplos

Entrada Saída Observação
3
3
3
3
0 1 0
1 2 1
0 1 0
10 20 30
40 50 60
70 80 90
50 60 61
80 90 91
81 91 92
8 9 19
9 10 20
39 40 50
Peso central \(2\) acelera o crescimento na dilatação e o encolhimento na erosão
🎮 Simulador: Pesos no Elemento Estruturante ⛰️ dil1 / ero1

Pesos b (clique e arraste o slider de cada célula)

f original

dil1(f,b)

ero1(f,b)

Figura 4.36: Simulador: Dilatação e Erosão com Pesos (mm.dil1 / mm.ero1)
import morph, testsuite
importlib.reload(morph); importlib.reload(testsuite)
from morph import mm
from testsuite import TestSuite
%%writefile EP04_07.py
# Código Python
Writing EP04_07.py
TestSuite("EP04_07.py").run()
✔️ EP04_07.cases já existe em casos/
📋 3 caso(s) carregado(s) de casos/EP04_07.cases

🔍 Testando Python: EP04_07.py
⚠️ EP04_07.py: Arquivo sem conteúdo (menos de 3 linhas). Testes ignorados.

4.9.8 EP04_08 🌋 Gradiente Morfológico, Top-hat e Black-hat

Em inspeção automática de placas de circuito, três perguntas aparecem o tempo todo: onde estão as bordas dos componentes? Quais detalhes claros e pequenos (como pontos de solda) se destacam do fundo? Quais reentrâncias escuras (como fissuras) o fundo esconde? Um único par erosão/dilatação responde às três: o gradiente morfológico evidencia contornos, o top-hat revela picos estreitos, e o black-hat revela vales estreitos — três ferramentas, uma só vizinhança. Ver na Figura 4.37 uma simulação deste EP.

4.9.8.1 📋 Diretrizes de Implementação

  1. Dimensões da imagem: Ler os inteiros \(L\) (linhas) e \(C\) (colunas) de \(f\).
  2. Dimensões de \(B\): Ler os inteiros \(L_B\) (linhas) e \(C_B\) (colunas) do elemento estruturante.
  3. Elemento estruturante: Ler a matriz \(B\) com valores \(0\) ou \(1\), linha a linha.
  4. Dados: Ler a matriz \(f\) (a imagem original, em tons de cinza), linha a linha.
  5. Operadores de base: Calcular, exatamente como nos EPs 04_03 a 04_06:
    • \(d = f \oplus B\) (dilatação),
    • \(e = f \ominus B\) (erosão),
    • \(\text{abertura} = e \oplus B\),
    • \(\text{fechamento} = d \ominus B\).
  6. Gradiente morfológico: \(\text{grad}(y,x) = d(y,x) - e(y,x)\).
  7. Top-hat: \(\text{tophat}(y,x) = f(y,x) - \text{abertura}(y,x)\).
  8. Black-hat: \(\text{blackhat}(y,x) = \text{fechamento}(y,x) - f(y,x)\).
  9. Saída: Exibir, nesta ordem, as três matrizes completas: gradiente, top-hat, black-hat.

4.9.8.2 📌 Restrições Computacionais

  • Sem padding em nenhuma etapa intermediária — dilatação, erosão, abertura e fechamento seguem as mesmas regras de vizinhança dos EPs anteriores.
  • Não há clipping: as três saídas podem conter qualquer valor inteiro (o gradiente é sempre \(\geq 0\), mas top-hat e black-hat também).
  • Reaproveitamento: \(d\) e \(e\) devem ser calculados uma única vez e reaproveitados para montar abertura, fechamento e gradiente.

4.9.8.3 🧠 Fundamentação Teórica

Operador Fórmula O que revela
Gradiente \(d - e\) Bordas: zero em regiões planas, alto nas transições
Top-hat \(f - \text{abertura}(f)\) Elementos claros e finos, menores que \(B\)
Black-hat \(\text{fechamento}(f) - f\) Elementos escuros e finos, menores que \(B\)

4.9.8.4 📦 Especificação de Entrada e Saída (VPL)

Entrada:

  • Linha 1: Inteiro \(L\).
  • Linha 2: Inteiro \(C\).
  • Linha 3: Inteiro \(L_B\).
  • Linha 4: Inteiro \(C_B\).
  • Próximas \(L_B\) linhas: elementos inteiros (\(0\) ou \(1\)) da matriz \(B\).
  • Próximas \(L\) linhas: elementos inteiros da matriz \(f\).

Saída:

  • Matriz gradiente em \(L\) linhas e \(C\) colunas.
  • Matriz top-hat em \(L\) linhas e \(C\) colunas.
  • Matriz black-hat em \(L\) linhas e \(C\) colunas.

4.9.8.5 📌 Exemplos

Entrada Saída Observação
9
9
3
3
1 1 1
1 1 1
1 1 1
10 10 10 10 10 10 10 10 10
10 10 10 10 10 10 10 10 10
10 10 80 10 10 10 10 10 10
10 10 10 10 10 10 10 10 10
10 10 10 10 10 10 10 10 10
10 10 10 10 10 10 10 10 10
10 10 10 10 10 10 2 10 10
10 10 10 10 10 10 10 10 10
10 10 10 10 10 10 10 10 10
(gradiente: halo \(3\times3=70\) em torno de \((2,2)\) e halo \(3\times3=8\) em torno de \((6,6)\), resto \(0\))
(top-hat: único \(70\) em \((2,2)\), resto \(0\))
(black-hat: único \(8\) em \((6,6)\), resto \(0\))
Pico isolado vira top-hat; vale isolado vira black-hat; ambos aparecem no gradiente
🎮 Simulador: Gradiente / Top-hat / Black-hat 🌋 3 operadores, 1 vizinhança

f

gradiente

top-hat

black-hat

Figura 4.37: Simulador: Gradiente Morfológico, Top-hat e Black-hat
%%writefile EP04_08.py
# Código Python
Writing EP04_08.py
TestSuite("EP04_08.py").run()
✔️ EP04_08.cases já existe em casos/
📋 4 caso(s) carregado(s) de casos/EP04_08.cases

🔍 Testando Python: EP04_08.py
⚠️ EP04_08.py: Arquivo sem conteúdo (menos de 3 linhas). Testes ignorados.

4.9.9 EP04_09 🗺️ Transformada de Distância e o “Miolo” do Objeto

Em robótica móvel, ao planejar uma rota dentro de um corredor, o robô quer saber não apenas onde existe espaço livre, mas também quão longe cada ponto livre está da parede mais próxima. Os caminhos mais seguros tendem a passar pelo “miolo” do corredor, longe dos obstáculos.

A transformada de distância morfológica atribui a cada pixel um valor que representa sua distância até a borda mais próxima, segundo a métrica definida pelo elemento estruturante. Pixels próximos à borda recebem valores baixos, enquanto pixels mais internos recebem valores maiores. O pixel de valor máximo corresponde à região mais protegida do objeto, frequentemente associada ao seu centro morfológico.

Ver na Figura 4.38 uma simulação deste EP.

4.9.9.1 📋 Diretrizes de Implementação

  1. Dimensões da imagem: ler os inteiros \(L\) (linhas) e \(C\) (colunas) da imagem \(f\).
  2. Dimensões de \(B\): ler os inteiros \(L_B\) (linhas) e \(C_B\) (colunas) do elemento estruturante.
  3. Elemento estruturante: ler a matriz \(b\), contendo valor \(0\) no centro e valores negativos nas demais posições.
  4. Imagem: ler a matriz binária \(f\) (valores \(0\) ou \(1\)), linha a linha.
  5. Preparação: multiplicar a imagem por \(L\times C\), garantindo que os pixels internos tenham valor inicial suficientemente alto para a propagação das distâncias.
  6. Transformada de distância: calcular a matriz de distâncias utilizando o método mm.dist1(f,b).
  7. Saída: exibir a matriz resultante da transformada de distância.

4.9.9.2 📌 Restrições Computacionais

  • Utilizar a implementação de erosão ponderada fornecida pela biblioteca.
  • O elemento estruturante pode conter valores negativos arbitrários.
  • A transformada deve ser obtida pela aplicação iterativa de erosões ponderadas até atingir um ponto fixo.

⚠️ Nota Crucial sobre Leitura de Matrizes: Como o elemento estruturante pode conter valores inteiros negativos (por exemplo, -1 e -99), não utilize a função mm.readImg para ler a matriz \(b\). Essa função converte os dados para o tipo uint8, provocando underflow e corrompendo os valores negativos. Leia as \(L_B\) linhas de \(b\) manualmente utilizando o tipo padrão int. A imagem \(f\) pode continuar sendo lida normalmente por mm.readImg.

4.9.9.3 🧠 Fundamentação Teórica

Conceito Significado Impacto Visual
\(\text{dist}(y,x)\) Distância morfológica até a borda mais próxima segundo a métrica definida por \(b\) Pixels mais internos recebem valores maiores
Valor máximo Pixel mais distante da borda Aproxima o centro morfológico do objeto
Elemento estruturante ponderado Define os custos de deslocamento entre pixels vizinhos Determina a métrica de distância utilizada
Objetos finos Regiões estreitas do objeto Produzem valores baixos de distância

4.9.9.4 📦 Especificação de Entrada e Saída (VPL)

Entrada:

  • Linha 1: inteiro \(L\).
  • Linha 2: inteiro \(C\).
  • Linha 3: inteiro \(L_B\).
  • Linha 4: inteiro \(C_B\).
  • Próximas \(L_B\) linhas: elementos inteiros da matriz \(b\).
  • Próximas \(L\) linhas: elementos binários (\(0\) ou \(1\)) da matriz \(f\).

⚠️ Nota de implementação: Os elementos da matriz \(f\) (0 ou 1) devem ser multiplicados por 255 para gerar uma imagem binária adequada (\(0\) e \(255\)) antes de aplicar a Transformada de Distância (TD).

Saída:

  • Matriz da transformada de distância em \(L\) linhas e \(C\) colunas.

4.9.9.5 📌 Exemplo

Entrada Saída Observação
5
9
3
3
-99 -1 -99
-1 0 -1
-99 -1 -99
0 0 0 0 0 0 0 0 0
0 1 1 1 1 1 1 1 0
0 1 1 1 1 1 1 1 0
0 1 1 1 1 1 1 1 0
0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0
0 1 1 1 1 1 1 1 0
0 1 2 2 2 2 2 1 0
0 1 1 1 1 1 1 1 0
0 0 0 0 0 0 0 0 0
Resultado da transformada de distância.

Nota: o valor -99 atua como uma aproximação prática de \(-\infty\), impedindo a propagação pelas diagonais. Dessa forma, apenas os vizinhos horizontal e vertical contribuem para a distância, produzindo a distância de Manhattan.

🎮 Simulador: Transformada de Distância 🗺️ camadas de erosão

Clique nas células para desenhar seu próprio objeto (mínimo 3 pixels)

Mapa de distâncias

Figura 4.38: Simulador: Transformada de Distância
%%writefile EP04_09.py
# Código Python
Writing EP04_09.py
TestSuite("EP04_09.py").run()
✔️ EP04_09.cases já existe em casos/
📋 4 caso(s) carregado(s) de casos/EP04_09.cases

🔍 Testando Python: EP04_09.py
⚠️ EP04_09.py: Arquivo sem conteúdo (menos de 3 linhas). Testes ignorados.

4.9.10 EP04_10 🪙 Separação de Blobs, Rotulação e Descritores

Em uma linha de produção de moedas, é comum que peças encostem umas nas outras na esteira, formando uma única mancha conectada na imagem — uma contagem ingênua erraria o total. A solução clássica combina operações morfológicas e análise de conectividade: primeiro uma erosão reduz ou rompe conexões frágeis entre objetos, e depois a rotulação de componentes conectados separa cada objeto em uma região distinta. Por fim, descritores geométricos (área e caixa delimitadora) resumem cada componente encontrado.

Ver na Figura 4.39 uma simulação deste EP.


4.9.10.1 📋 Diretrizes de Implementação

  1. Dimensões da imagem: ler os inteiros \(L\) (linhas) e \(C\) (colunas) de \(f\).

  2. Dimensões de \(B\): ler os inteiros \(L_B\) (linhas) e \(C_B\) (colunas) do elemento estruturante.

  3. Elemento estruturante: ler a matriz \(B\), contendo valores \(0\) ou \(1\), linha a linha.

  4. Dados: ler a matriz binária \(f\) (valores \(0\) ou \(1\)), linha a linha.

  5. Separação: calcular \[ f_{ero} = f \ominus B \] usando erosão binária plana (como no EP04_04), eliminando conexões frágeis entre objetos.

  6. Rotulação: sobre \(f_{ero}\), identificar componentes conectados usando conectividade definida pela vizinhança \(B\). A rotulação deve seguir varredura raster: ao encontrar um pixel \(1\) ainda não rotulado, atribuir um novo rótulo inteiro crescente a partir de 1 e propagar esse rótulo a toda a região conectada.

  7. Descritores: para cada rótulo \(k\), calcular:

    • Área: número de pixels pertencentes ao rótulo;
    • Caixa delimitadora: \[(y_{min}, x_{min}, y_{max}, x_{max})\]
  8. Saída: exibir o número total de rótulos e, em seguida, uma linha por rótulo no formato: \[ k,\ \text{área},\ y_{min},\ x_{min},\ y_{max},\ x_{max} \]


4.9.10.2 📌 Restrições Computacionais

  • A erosão deve ser aplicada antes da rotulação.
  • A conectividade é fixa e definida pela vizinhança acima.
  • O elemento estruturante \(B\) não interfere na conectividade da rotulação.
  • Sem padding em qualquer etapa.
  • A ordem dos rótulos segue a primeira descoberta em varredura raster.

4.9.10.3 🧠 Fundamentação Teórica

Conceito Significado Impacto
Ponte fina Conexão estreita entre objetos Pode ser removida pela erosão morfológica
Conectividade Definida pelo conjunto \[\mathcal{N}(y,x)\] Determina quais pixels pertencem ao mesmo componente
Área Número de pixels por componente Estimativa direta do tamanho do objeto
Caixa delimitadora Extensão espacial do rótulo Resumo geométrico do componente

4.9.10.4 📦 Especificação de Entrada e Saída (VPL)

Entrada:

  • Linha 1: inteiro \(L\)
  • Linha 2: inteiro \(C\)
  • Linha 3: inteiro \(L_B\)
  • Linha 4: inteiro \(C_B\)
  • Próximas \(L_B\) linhas: matriz \(B\)
  • Próximas \(L\) linhas: matriz \(f\)

Saída:

  • Linha 1: número total de rótulos encontrados
  • Linhas seguintes: \[ k,\ \text{área},\ y_{min},\ x_{min},\ y_{max},\ x_{max} \]
🎮 Simulador: Moedas Coladas → Separadas → Contadas 🪙 erosão + rótulo + descritores

1 px

f original (ligadas)

após erosão + rótulos

Figura 4.39: Simulador: Separação de Blobs, Rotulação e Descritores
%%writefile EP04_10.py
# Código Python
Writing EP04_10.py
TestSuite("EP04_10.py").run()
✔️ EP04_10.cases já existe em casos/
📋 4 caso(s) carregado(s) de casos/EP04_10.cases

🔍 Testando Python: EP04_10.py
⚠️ EP04_10.py: Arquivo sem conteúdo (menos de 3 linhas). Testes ignorados.