3  Operações Espaciais: Intensidade, Histograma e Filtragem

Este capítulo aprofunda o processamento de imagens no domínio espacial, partindo da manipulação direta de pixels e histogramas para o realce de contraste, até a aplicação de filtros locais por convolução para suavização, redução de ruído e detecção de bordas. O objetivo é desenvolver a intuição matemática e computacional que sustenta grande parte dos algoritmos modernos de Visão Computacional.

3.1 Objetivos

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

  • Manipular intensidade e pixels: Executar operações aritméticas saturadas (mm.addm, mm.subm) e lógicas bit a bit (mm.band, mm.bor, mm.bnot) para combinação e seleção de regiões de interesse (ROI), e aplicar alpha blending (mm.blend) para fusão ponderada de imagens;
  • Processar histogramas: Interpretar o histograma como diagnóstico tonal, aplicar equalização global (mm.equalize) e adaptativa (CLAHE), e realizar especificação de histograma para transferência de perfil tonal entre imagens;
  • Compreender fundamentos espaciais: Entender vizinhança, padding de borda, e a diferença entre correlação cruzada (mm.conv) e convolução — incluindo por que kernels assimétricos como Sobel produzem resultados distintos nas duas operações;
  • Aplicar filtragem de suavização: Utilizar o filtro de média (mm.conv com kernel uniforme) e o filtro Gaussiano (cv2.GaussianBlur) para redução de ruído, compreendendo a vantagem da ponderação radial e da separabilidade Gaussiana;
  • Aplicar filtragem de realce: Usar o Laplaciano (\(w_4\) e \(w_8\)) para realce isotrópico de bordas, o operador de Sobel para gradiente direcional (\(G_x\), \(G_y\), magnitude e direção), e o Unsharp Masking para amplificação controlada de alta frequência pelo parâmetro \(k\);
  • Utilizar filtros de ordem: Aplicar o filtro da mediana (cv2.medianBlur) para remoção eficaz de ruído sal e pimenta, compreendendo por que sua natureza não linear e robustez a outliers o tornam superior aos filtros lineares nesse cenário;
  • Resolver problemas práticos: Encadear técnicas em pipelines de pré-processamento (ex.: CLAHE → Gaussiano → Canny) e utilizar as funções da biblioteca morph.py (mm.conv, mm.histImg, mm.equalize, mm.drawImageKernel) para análise e visualização didática de cada etapa.

3.2 Operações em Nível de Intensidade

O nível mais elementar de processamento de imagens atua diretamente sobre os valores dos pixels, sem considerar vizinhança. Essas operações — chamadas de transformações de ponto (point operations) — são as mais rápidas computacionalmente e formam a base para técnicas mais complexas.

Formalmente, uma transformação de ponto pode ser descrita como:

\[ g(x,y) = T[f(x,y)] \tag{3.1}\]

onde \(f(x,y)\) é a imagem de entrada, \(g(x,y)\) é a saída e \(T\) é uma função aplicada a cada pixel individualmente.

import os, importlib, urllib.request
import numpy as np
import matplotlib.pyplot as plt
import cv2

# Baixar morph.py se necessário
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

print("✅ Ambiente pronto")
✅ Ambiente pronto

Como objeto de estudo ao longo deste capítulo, utilizaremos as imagens de vida selvagem apresentadas nas Figura 3.1 e Figura 3.3. A partir delas, analisaremos a estrutura de matrizes multidimensionais, a conversão para tons de cinza e a extração de metadados espaciais obtidos diretamente do cabeçalho EXIF dos arquivos originais.

import os
from PIL.ExifTags import TAGS

# Fonte : https://commons.wikimedia.org/wiki/File:Carlos_Guilherme_Rodrigues_(76515283).jpeg
# Mapa  : https://www.google.com/maps?q=-26.204734,28.226624

base    = "https://upload.wikimedia.org/wikipedia/commons"
arquivo = "Carlos_Guilherme_Rodrigues_%2876515283%29.jpeg"
url     = f"{base}/9/9b/{arquivo}"
caminho = "imagens/mandrill-exif.jpg"

# 1. Leitura com cache local
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_color = np.array(img_obj)
img_gray  = mm.gray(img_color)

# 2. Extração e conversão de GPS (Tag 34853)
exif = img_obj._getexif()
if exif and (gps := exif.get(34853)):
    to_dec = lambda dms, ref: float(-(dms[0]+dms[1]/60+dms[2]/3600) if ref in 'SW'
                                    else (dms[0]+dms[1]/60+dms[2]/3600))
    lat, lon = to_dec(gps[2], gps[1]), to_dec(gps[4], gps[3])
    print(f"GPS Decimal: {lat:.6f}, {lon:.6f}")
    print(f"Maps: https://www.google.com/maps/search/?api=1&query={lat},{lon}")

# 3. Diagnóstico de tipos, dimensões e acesso a pixels
print(f"\nTipo PIL  : {type(img_obj)}   | Dimensões (x,y): {img_obj.size}")
print(f"Tipo NumPy: {type(img_color)} | Dimensões [y,x,c]: {img_color.shape}")

# 4. Exibição
mm.show(img_color, scale=90)

Tipo PIL  : <class 'PIL.JpegImagePlugin.JpegImageFile'>   | Dimensões (x,y): (960, 540)
Tipo NumPy: <class 'numpy.ndarray'> | Dimensões [y,x,c]: (540, 960, 3)
Figura 3.1: Mandrill (Mandrillus sphinx) fotografado em ambiente natural na África do Sul. A imagem possui resolução de 500 px e contém metadados EXIF com coordenadas GPS aproximadas (-26.20473, 28.22662). Crédito: Carlos Guilherme Rodrigues (CC BY-SA 3.0).

3.2.1 Operações Aritméticas

Operações aritméticas entre imagens são amplamente usadas em PDI para combinar, comparar ou realçar informações. A subtração de imagens é especialmente poderosa para detectar diferenças entre dois quadros — por exemplo, na remoção de fundo estático em câmeras de vigilância:

\[ g(x,y) = f_1(x,y) - f_2(x,y) \tag{3.2}\]

A adição saturada limita o resultado ao intervalo \([0, 255]\): valores acima de 255 são fixados em 255, evitando o overflow silencioso do tipo uint8 (ex.: \(200 + 100 = 44\) em vez de 300). A subtração saturada aplica o mesmo princípio pelo lado inferior: valores negativos são fixados em 0.

AvisoSaturação e overflow

Operações aritméticas em uint8 sofrem overflow silencioso: \(200 + 100 = 44\) (não 300). As funções mm.addm e mm.subm usam cv2.add e cv2.subtract, que realizam saturação automática. Para o blending, converta para float32 antes de operar e aplique np.clip(..., 0, 255).astype(np.uint8) ao final, pois a operação envolve pesos fracionários.

A Figura 3.2 demonstra adição de uma constante (clareamento) e subtração de uma constante (escurecimento com saturação em 0).

fundo = 60

img_add = mm.addm(img_gray, fundo)
img_sub = mm.subm(img_gray, fundo)

mm.show(
    [img_gray, img_add, img_sub],
    titles=["Original", "addm (+60)", "subm (−60)"],
    cols=3
)
Figura 3.2: Operações aritméticas saturadas: adição de constante (clareamento) e subtração de constante (escurecimento com saturação em 0).

3.2.2 Mistura Ponderada (Alpha Blending)

A mistura ponderada (alpha blending) combina duas imagens utilizando pesos complementares \(\alpha\) e \((1-\alpha)\):

\[ g(x,y) = \alpha\,f_1(x,y) + (1-\alpha)\,f_2(x,y), \quad \alpha \in [0,1] \tag{3.3}\]

Quando \(\alpha = 1\), obtém-se apenas a imagem \(f_1\); quando \(\alpha = 0\), apenas \(f_2\). Valores intermediários produzem uma transição suave entre ambas, sendo amplamente utilizados em composição de imagens, sobreposição de camadas, marcas d’água e efeitos de fusão visual.

Para que a combinação produza um resultado coerente, é necessário alinhar previamente as regiões de interesse das imagens. Na Figura 3.4, utiliza-se um recorte do rosto do leopardo:

leo = img_gray_leo[250:-300, 100:-200]

e um recorte central da região facial do mandril:

mandrill = img_gray[100:400, 380:530]

As duas regiões são escolhidas de forma que olhos e estrutura facial permaneçam aproximadamente alinhados. Em seguida, o recorte do leopardo é redimensionado para coincidir com as dimensões do mandril antes da aplicação da mistura ponderada.

A operação é realizada em float32 para evitar problemas de overflow durante as operações aritméticas, seguida de clipping e conversão final para uint8.

import os
from PIL.ExifTags import TAGS

base    = "https://upload.wikimedia.org/wikipedia/commons"
arquivo = "Leopard_%28Panthera_pardus%29_portrait.jpg"
url     = f"{base}/9/92/{arquivo}"
caminho = "imagens/leopardo.jpg"

# 1. Leitura com cache local
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_numpy    = np.array(img_obj)
img_gray_leo = mm.gray(img_numpy)

# 2. Extração e conversão de GPS (Tag 34853)
exif = img_obj._getexif()
if exif and (gps := exif.get(34853)):
    to_dec = lambda dms, ref: float(
        -(dms[0]+dms[1]/60+dms[2]/3600) if ref in 'SW'
        else (dms[0]+dms[1]/60+dms[2]/3600)
    )
    lat, lon = to_dec(gps[2], gps[1]), to_dec(gps[4], gps[3])
    print(f"GPS Decimal: {lat:.6f}, {lon:.6f}")
    print(f"Maps: https://www.google.com/maps/search/?api=1&query={lat},{lon}")

# 3. Diagnóstico de tipos, dimensões e acesso a pixels
print(f"\nTipo PIL  : {type(img_obj)}   | Dimensões (x,y): {img_obj.size}")
print(f"Pillow (0,0): {img_obj.getpixel((0, 0))}")
print(f"Tipo NumPy: {type(img_numpy)} | Dimensões [y,x,c]: {img_numpy.shape}")
print(f"NumPy [0,0]: {img_numpy[0, 0]}")

# 4. Exibição
mm.show(img_numpy)
GPS Decimal: -19.140200, 23.814872
Maps: https://www.google.com/maps/search/?api=1&query=-19.1402,23.814872222222224

Tipo PIL  : <class 'PIL.JpegImagePlugin.JpegImageFile'>   | Dimensões (x,y): (1242, 1324)
Pillow (0,0): (191, 135, 88)
Tipo NumPy: <class 'numpy.ndarray'> | Dimensões [y,x,c]: (1324, 1242, 3)
NumPy [0,0]: [191 135  88]
Figura 3.3: Retrato de um leopardo (Panthera pardus) em ambiente natural. Crédito: C. Brück (CC BY-SA 4.0).
leo = img_gray_leo[250:-300, 100:-200]
mandrill = img_gray[100:400, 380:530]
img_leopardo = mm.resize(leo, (mandrill.shape[1], mandrill.shape[0]), method='bilinear')

alphas = [1.0, 0.8, 0.6, 0.4, 0.2, 0.0]
imgs_blend   = []
titles_blend = []

for a in alphas:
    imgs_blend.append(mm.blend(mandrill, img_leopardo, alpha=a))
    titles_blend.append(f"α={a:.1f}")

mm.show(imgs_blend, titles=titles_blend, cols=6, figsize=(18, 16))
Figura 3.4: Alpha blending entre recortes alinhados de mandrill e do leopardo (Figura 3.3) para diferentes valores de α. Em α=1 vê-se apenas mandrill; em α=0, apenas o leopardo; valores intermediários fundem os olhares das duas imagens proporcionalmente.

3.2.3 Operações Lógicas e Máscaras Bit a Bit

As operações lógicas bit a bit (AND, OR e NOT) atuam diretamente sobre os bits de cada pixel e são a base para criação e aplicação de máscaras (masks) — imagens binárias com apenas 0 (preto) e 255 (branco) usadas para isolar Regiões de Interesse (ROI).

O comportamento de cada operação decorre da representação binária do 255 (11111111) e do 0 (00000000):

  • AND com a máscara: onde \(m = 255\), os bits originais são preservados; onde \(m = 0\), o pixel é zerado. Resultado: recorte da ROI. \[g(x,y) = f(x,y) \;\text{AND}\; m(x,y) \tag{3.4}\]
  • OR com a máscara: onde \(m = 255\), o pixel é forçado a branco; onde \(m = 0\), o valor original é mantido. Resultado: iluminação da ROI.
  • NOT (sem máscara): inverte todos os bits (\(g = 255 - f\)), produzindo o negativo fotográfico da imagem.

A Figura 3.5 ilustra as três operações aplicadas à imagem do mandrill com uma máscara circular.

h, w = img_gray.shape

# Máscara circular centrada na imagem
mask_circ = np.zeros((h, w), dtype=np.uint8)
cv2.circle(mask_circ, (w//2, h//2), min(h, w)//3 - 10, 255, -1)

# Operações via morph
img_not = mm.bnot(img_gray)            # NOT: negativo fotográfico
img_and = mm.band(img_gray, mask_circ) # img_gray & mask_circ: preserva apenas a ROI circular
img_or  = mm.bor(img_gray, mask_circ)  # img_gray | mask_circ: ilumina a região da máscara

mm.show(
    [img_gray, img_and, img_or, img_not],
    titles=["Original", "AND (ROI circular)", "OR (ilumina ROI)", "NOT (negativo)"],
    cols=4
)
Figura 3.5: Operações lógicas bit a bit com máscara circular: NOT (negativo), AND (isolamento da ROI) e OR (iluminação da ROI).

3.3 Histograma de Imagens

O histograma de uma imagem em tons de cinza é uma função discreta que descreve a distribuição de frequências das intensidades:

\[ h(r_k) = n_k, \quad k = 0, 1, \ldots, L-1 \tag{3.5}\]

onde \(r_k\) é o \(k\)-ésimo nível de intensidade, \(n_k\) é o número de pixels com essa intensidade e \(L\) é o total de níveis (tipicamente 256 para 8 bits). O histograma normalizado estima a probabilidade de cada nível:

\[ p(r_k) = \frac{n_k}{MN} \tag{3.6}\]

onde \(MN\) é o total de pixels. Por ser uma estatística global, o histograma não carrega informação posicional, mas revela características essenciais como brilho médio, contraste e distribuição tonal. Na prática, mm.hist(img) retorna o vetor de contagens \(h(r_k)\), que serve tanto para visualização (via mm.histImg) quanto para cálculos como função de distribuição acumulada (CDF) e equalização.

NotaInterpretação do Histograma
  • Estreito à esquerda: imagem subexposta (escura).
  • Estreito à direita: imagem superexposta (clara).
  • Concentrado no centro: baixo contraste.
  • Distribuído por toda a faixa: alto contraste, boa utilização dos tons disponíveis.

A Figura 3.6 exibe o histograma da imagem do mandrill, de uma versão escurecida (mm.subm) e de uma versão clareada (mm.addm), evidenciando o deslocamento da distribuição para a esquerda e para a direita, respectivamente.

img_dark = mm.subm(img_gray, 80)
img_high = mm.addm(img_gray, 80)

images = [img_gray, img_dark, img_high]
titles = ["Original", "Escurecida (−80)", "Clareada (+80)"]

def hist_title(img, t):
    H = mm.hist(img)
    n0  = int(H[0])
    n255 = int(H[255]) if len(H) > 255 else 0
    return f"Histograma — {t}\n0:{n0:,}px  255:{n255:,}px"

mm.show(
    images + [mm.histImg(img) for img in images],
    titles=titles + [hist_title(img, t) for img, t in zip(images, titles)],
    rows=2, cols=3,
    figsize=(14, 8)
)
Figura 3.6: Histogramas da imagem original, de uma versão escurecida e de uma versão clareada. Os valores entre parênteses indicam a quantidade de pixels saturados em 0 (preto) e 255 (branco).

3.3.1 Equalização de Histograma

A equalização de histograma redistribui as intensidades para que o histograma resultante seja o mais uniforme possível. O mapeamento é dado pela função de distribuição acumulada (CDF):

\[ s_k = T(r_k) = (L-1)\sum_{j=0}^{k} p(r_j) = \frac{L-1}{MN}\sum_{j=0}^{k} n_j \tag{3.7}\]

A transformação é monotônica: níveis frequentes recebem intervalos maiores no domínio de saída (maior separação → mais contraste), enquanto níveis raros são comprimidos.

O algoritmo completo, em cinco etapas, é apresentado na Tabela 3.1.

Tabela 3.1: Algoritmo de equalização de histograma.
Etapa Operação Fórmula
1 Histograma \(h[k] \leftarrow\) número de pixels com intensidade \(k\), \(k=0\ldots L-1\)
2 Probabilidade \(p[k] \leftarrow h[k] / MN\)
3 CDF \(\text{cdf}[k] \leftarrow \sum_{j=0}^{k} p[j]\) (soma acumulada)
4 Mapeamento (LUT) \(\text{lut}[k] \leftarrow \text{round}(\text{cdf}[k] \times (L-1))\)
5 Aplicação \(g[i,j] \leftarrow \text{lut}[f[i,j]]\) (para todo pixel)

Note na Figura 3.7 que a equalização redistribui os tons existentes para posições mais espaçadas na faixa \([0, L-1]\), mas não cria novos tons — a imagem equalizada continua com exatamente 3 tons distintos, agora em \(\{1, 5, 7\}\) em vez de \(\{2, 3, 4\}\).

img5 = np.array([[3, 4, 2, 3, 4],
                 [4, 3, 3, 4, 3],
                 [2, 3, 4, 3, 2],
                 [3, 4, 3, 2, 3],
                 [4, 3, 2, 3, 4]], dtype=np.uint8)
L = 8  # 3 bits: níveis 0..7

# 1. Histograma com tamanho garantido até L
h_raw = mm.hist(img5)
h = np.zeros(L, dtype=int)
h[:len(h_raw)] = h_raw  # h[k] = nº pixels com intensidade k

p   = h / h.sum()  # 2. Probabilidade de cada nível p[k] = h[k] / MN

cdf = np.cumsum(p) # 3. CDF normalizada ou
# cdf = np.zeros(len(p))
# cdf[0] = p[0]
# for k in range(1, len(p)):
#     cdf[k] = cdf[k-1] + p[k] # cdf[k] = Σ p[j], j=0..k

lut = np.round(cdf * (L - 1)).astype(np.uint8)  # 4. LUT: mapeamento para [0, L-1]

img5_eq = lut[img5] # 5. Aplica LUT pixel a pixel ou, equivalente a:
# l, c = img5.shape
# img5_eq = np.zeros((l, c), dtype=np.uint8)
# for i in range(l):
#     for j in range(c):
#         img5_eq[i, j] = lut[img5[i, j]]      # g[i,j] = lut[f[i,j]]

print("Imagem original (5×5, 3 bits):")
print(img5)
print()
header = f"{'k':>3} {'h[k]':>6} {'p[k]':>7} {'CDF[k]':>8} {'lut[k]':>7}"
print(header)
print("-" * len(header))
for k in range(L):
    print(f"{k:>3} {h[k]:>6} {p[k]:>7.4f} {cdf[k]:>8.4f} {lut[k]:>7}")
print()
print("Imagem equalizada (5×5):")
print(img5_eq)

# Conta tons distintos
tons_orig = len(np.unique(img5))
tons_eq   = len(np.unique(img5_eq))

mm.show(
    [img5, img5_eq, mm.histImg(img5, L-1), mm.histImg(img5_eq, L-1)],
    titles=[f"Original ({tons_orig} tons: {sorted(np.unique(img5).tolist())})",
            f"Equalizada ({tons_eq} tons: {sorted(np.unique(img5_eq).tolist())})",
            "Histograma — Original",
            "Histograma — Equalizada"],
    rows=2, cols=2, figsize=(5, 4), dpi=100
)
Imagem original (5×5, 3 bits):
[[3 4 2 3 4]
 [4 3 3 4 3]
 [2 3 4 3 2]
 [3 4 3 2 3]
 [4 3 2 3 4]]

  k   h[k]    p[k]   CDF[k]  lut[k]
-----------------------------------
  0      0  0.0000   0.0000       0
  1      0  0.0000   0.0000       0
  2      5  0.2000   0.2000       1
  3     12  0.4800   0.6800       5
  4      8  0.3200   1.0000       7
  5      0  0.0000   1.0000       7
  6      0  0.0000   1.0000       7
  7      0  0.0000   1.0000       7

Imagem equalizada (5×5):
[[5 7 1 5 7]
 [7 5 5 7 5]
 [1 5 7 5 1]
 [5 7 5 1 5]
 [7 5 1 5 7]]
Figura 3.7: Equalização de histograma em imagem 5×5 com 3 bits (L=8): imagem original com tons concentrados em {2,3,4}, imagem equalizada com tons redistribuídos para {1,5,7}, e os respectivos histogramas evidenciando o espalhamento das frequências.

Limitação: a equalização global pode super-realçar ruídos em regiões homogêneas. O CLAHE (Contrast Limited Adaptive Histogram Equalization) resolve isso aplicando a equalização em blocos locais com um limite máximo de clip para o histograma de cada bloco.

A Figura 3.8 compara a imagem original, a equalização via mm.equalize e o CLAHE do OpenCV, exibindo também os histogramas resultantes.

img_eq    = mm.equalize(img_gray)
clahe     = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(32, 32))
img_clahe = clahe.apply(img_gray)

images = [img_gray, img_eq, img_clahe]
titles = ["Original", "mm.equalize (CDF)", "CLAHE"]

mm.show(
    images + [mm.histImg(img) for img in images],
    titles=titles + [f"Histograma — {t}" for t in titles],
    rows=2, cols=3,
    figsize=(14, 8)
)
Figura 3.8: Equalização de histograma: mm.equalize (global via CDF) vs. CLAHE (adaptativa com limitação de contraste). Os histogramas revelam o espalhamento progressivo das intensidades.

3.3.2 Especificação de Histograma

Enquanto a equalização impõe uma distribuição uniforme, a especificação de histograma (histogram matching) permite que o histograma da imagem de saída siga uma distribuição arbitrária — por exemplo, o histograma de outra imagem de referência.

O procedimento envolve três etapas:

  1. Calcular a CDF da imagem de entrada: \(P_r(r_k)\).
  2. Calcular a CDF da imagem de referência: \(P_z(z_k)\).
  3. Para cada nível \(r_k\), encontrar o nível \(z\) que minimiza \(|P_z(z) - P_r(r_k)|\).

\[ T(r_k) = \arg\min_{z}\,|P_z(z) - P_r(r_k)| \tag{3.8}\]

Na Figura 3.9, transferimos o perfil tonal do leopardo (Figura 3.3) para a imagem do mandrill — uma aplicação direta do conceito visto no blending: em vez de fundir pixels, aqui fundimos distribuições tonais.

img_leop_gray = mm.gray(img_numpy)

def hist_specify(src, ref):
    """Mapeia src para o perfil tonal de ref via CDF."""
    cdf_src = np.cumsum(mm.hist(src) / src.size)
    cdf_ref = np.cumsum(mm.hist(ref) / ref.size)
    lut = np.array([np.argmin(np.abs(cdf_ref - v)) for v in cdf_src], dtype=np.uint8)
    return lut[src]

img_spec = hist_specify(img_gray, img_leop_gray)

images = [img_gray, img_leop_gray, img_spec]
titles = ["Mandrill (original)", "Leopardo (referência)", "Mandrill → perfil do leopardo"]

mm.show(
    images + [mm.histImg(img) for img in images],
    titles=titles + [f"Histograma — {t}" for t in titles],
    rows=2, cols=3,
    figsize=(12, 9)
)
Figura 3.9: Especificação de histograma: mandril mapeada para o perfil tonal do leopardo. A CDF da saída aproxima a CDF de referência.

3.4 Fundamentos Espaciais: Vizinhança, Convolução e Kernels

As operações de filtragem espacial operam sobre regiões vizinhas de pixels — não mais pixel a pixel. O conceito central é a janela deslizante (sliding window): um kernel \(w\) de dimensão \((2a+1)\times(2b+1)\) percorre toda a imagem e, para cada posição \((x,y)\), calcula uma nova intensidade combinando os pixels da vizinhança com os coeficientes do kernel.

3.4.1 Vizinhança e Borda

Para um pixel \((x,y)\), a vizinhança retangular de raio \((a,b)\) é o conjunto:

\[ \mathcal{V}(x,y) = \{(x+s,\, y+t) : -a \le s \le a,\; -b \le t \le b\} \tag{3.9}\]

Pixels próximos à borda da imagem têm vizinhanças parcialmente fora do domínio. As estratégias mais comuns são: zero-padding (completa com zeros), replicação (repete o pixel de borda) e reflexão (espelha a imagem). O OpenCV usa replicação por padrão (cv2.BORDER_REFLECT_101).

3.4.2 Correlação e Convolução

Existem dois mecanismos matematicamente relacionados:

Correlação cruzada (cross-correlation) — o kernel é aplicado diretamente:

\[ g(x,y) = \sum_{s=-a}^{a}\sum_{t=-b}^{b} w(s,t)\,f(x+s,\,y+t) \tag{3.10}\]

Convolução bidimensional — o kernel é rotacionado 180° antes da aplicação:

\[ g(x,y) = \sum_{s=-a}^{a}\sum_{t=-b}^{b} w(s,t)\,f(x-s,\,y-t) \tag{3.11}\]

Para kernels simétricos (Gaussiano, Laplaciano, média) as duas operações produzem resultados idênticos. Para kernels assimétricos (Sobel, Prewitt) a diferença é significativa.

NotaCorrelação vs. Convolução no OpenCV

cv2.filter2D implementa correlação. Para convolução verdadeira com kernel assimétrico, rotacione o kernel 180° (np.rot90(w, 2)) antes de passar para filter2D.

3.4.3 O Papel do Kernel

Os coeficientes do kernel determinam completamente o efeito do filtro:

Tabela 3.2: Interpretação dos coeficientes do kernel.
Soma Coeficientes Efeito
= 1 todos positivos Suavização — passa-baixa
= 0 positivos e negativos Detecção de bordas — passa-alta
= 1 centro \(> 1\), bordas \(< 0\) Realce de nitidez
assimétricos Gradiente direcional

A Figura 3.10 demonstra o mecanismo passo a passo: para cada posição da janela, multiplica-se o kernel pela vizinhança e soma-se o resultado — exatamente a Equação 3.10 avaliada em um único ponto.

import time

w_mean = np.ones((3,3), dtype=np.float32) / 9.0
img_gray = img_leop_gray
# Demonstração numérica em patch 5×5
patch = img_gray[250:255, 250:255].astype(np.float32)
roi   = patch[0:3, 0:3]
print("Patch 5×5 (intensidades):")
print(patch.astype(np.int32))
print(f"\nKernel 3×3 de média:\n{w_mean}")
print(f"\nCorrelação no pixel central [1,1]: \
      {(w_mean * roi).sum():.1f}  (original: {patch[1,1]:.0f})")

# Comparação de tempo na imagem completa (Mandrill)
t0 = time.perf_counter()
img_conv0 = mm.conv0(img_gray, w_mean)
t_conv0 = time.perf_counter() - t0

t0 = time.perf_counter()
img_conv = mm.conv(img_gray, w_mean)
t_conv = time.perf_counter() - t0

diff = np.abs(img_conv0.astype(int) - img_conv.astype(int)).max()
print(f"\nTempos na imagem {img_gray.shape} (Mandrill):")
print(f"  mm.conv0 (laços Python): {t_conv0:.3f} s")
print(f"  mm.conv  (cv2.filter2D): {t_conv:.5f} s")
print(f"  Aceleração:              {t_conv0/t_conv:.0f}× mais rápido")
print(f"  Diferença máxima:        {diff} (bordas com padding diferente)")

mm.show(
    [img_gray, img_conv0, img_conv],
    titles=["Original", f"conv0 ({t_conv0:.2f}s)", f"conv ({t_conv:.4f}s)"],
    cols=3
)
Patch 5×5 (intensidades):
[[ 95  97 103 113 115]
 [107 105 103 108 115]
 [ 98 102 112 118 116]
 [ 81  91 113 123 119]
 [ 85  91 111 120 121]]

Kernel 3×3 de média:
[[0.11111111 0.11111111 0.11111111]
 [0.11111111 0.11111111 0.11111111]
 [0.11111111 0.11111111 0.11111111]]

Correlação no pixel central [1,1]:       102.4  (original: 105)

Tempos na imagem (1324, 1242) (Mandrill):
  mm.conv0 (laços Python): 8.742 s
  mm.conv  (cv2.filter2D): 0.00206 s
  Aceleração:              4247× mais rápido
  Diferença máxima:        43 (bordas com padding diferente)
Figura 3.10: Correlação com kernel de média 3×3: versão didática (mm.conv0) vs. vetorizada (mm.conv via cv2.filter2D). A diferença máxima de 27 ocorre nas bordas, onde o tratamento de padding difere.

3.4.4 Exemplo Numérico: Correlação Passo a Passo

Para tornar concreto o mecanismo da Equação 3.10, considere o kernel de média 3×3 (\(a=b=1\), todos os coeficientes \(= 1/9 \approx 0{,}111\)) aplicado ao patch 5×5 extraído da imagem do mandrill. A Figura 3.11 exibe o patch com grade e destaca em amarelo a janela 3×3 centrada no pixel \([1,1]\):

patch = img_gray[250:255, 250:255]
B     = np.ones((3,3), dtype=np.uint8)

print("Patch 5×5 (intensidades):")
print(mm.drawImage(patch))

mm.drawImageKernel(patch, B, x=1, y=1, scale=40.0)
Patch 5×5 (intensidades):
 95  97 103 113 115 
107 105 103 108 115 
 98 102 112 118 116 
 81  91 113 123 119 
 85  91 111 120 121 
Figura 3.11: Patch 5×5 extraído da imagem do mandrill (posição [250:255, 250:255]). A janela amarela destaca a vizinhança 3×3 centrada no pixel [1,1] onde a correlação será calculada.

Os valores da vizinhança 3×3 centrada em \([1,1]\) são exatamente a submatriz superior esquerda do patch:

\[ \text{vizinhança} = \begin{bmatrix} 187 & 189 & 192 \\ 190 & 196 & 197 \\ 193 & 197 & 199 \end{bmatrix} \]

Aplicando a Equação 3.10 com o kernel de média:

\[ g[1,1] = \frac{187+189+192+190+196+197+193+197+199}{9} = \frac{1740}{9} \approx 193 \]

O resultado (193) é ligeiramente menor que o original (196) porque a média inclui vizinhos de menor intensidade — efeito típico de suavização. A janela desliza então para o próximo pixel \([1,2]\), recalcula com uma nova vizinhança 3×3, e assim sucessivamente até cobrir toda a imagem.

AvisoDesempenho: laços Python vs. operações vetorizadas

A função mm.conv0 realiza a convolução com laços Python explícitos, processando pixel a pixel. Na imagem Mandrill \((540\times960)\), isso levou cerca de 3 segundos para um kernel \(3\times3\).

mm.conv utiliza cv2.filter2D, implementado em C++ com operações vetorizadas e otimizações internas, executando a mesma operação em apenas 0.00068 s — aproximadamente 4448× mais rápido:

mm.conv0 (laços Python): 3.005 s
mm.conv  (cv2.filter2D): 0.00068 s
Aceleração:              4448× mais rápido

As pequenas diferenças entre os resultados (diferença máxima igual a 27) decorrem principalmente do tratamento distinto das bordas (padding).

Por isso, mm.conv0 deve ser usado apenas para fins didáticos. Em aplicações reais, utilize sempre implementações vetorizadas como mm.conv.

3.5 Filtragem Espacial de Suavização

Os filtros de suavização (smoothing filters) atenuam variações bruscas de intensidade, reduzindo ruído e detalhes de alta frequência. São filtros passa-baixa — preservam as componentes de baixa frequência (estruturas grandes) e atenuam as de alta frequência (ruído, bordas).

3.5.1 Filtro de Média (Box Filter)

O filtro de média utiliza um kernel uniforme de tamanho \(n \times n\), onde todos os coeficientes valem \(1/n^2\):

\[ w_{\text{média}} = \frac{1}{n^2} \begin{bmatrix} 1 & \cdots & 1 \\ \vdots & \ddots & \vdots \\ 1 & \cdots & 1 \end{bmatrix}_{n \times n} \tag{3.12}\]

Cada pixel de saída é a média aritmética dos \(n^2\) pixels de sua vizinhança. Note que a soma dos coeficientes é sempre 1 — o brilho médio da imagem é preservado. Kernels maiores produzem suavização mais agressiva, mas borram progressivamente as bordas.

A Figura 3.12 compara o efeito de kernels de média com tamanhos crescentes na imagem do mandrill completa. Observe como o borramento das bordas aumenta proporcionalmente ao tamanho do kernel.

# Detalhe da região do olho
y0, y1, x0, x1 = 580, 740, 680, 900
crop = lambda img: img[y0:y1, x0:x1]

img_gray_crop = crop(img_gray)

sizes = [3, 7, 15]
imgs  = [img_gray_crop] + \
    [mm.conv(img_gray_crop, np.ones((k,k), dtype=np.float32)/(k*k)) for k in sizes]
titles = ["Original"] + [f"Média {k}×{k}" for k in sizes]

mm.show(imgs, titles=titles, cols=4)
Figura 3.12: Filtro de média com kernels de tamanho crescente (3×3, 7×7, 15×15). O borramento das bordas aumenta com o tamanho do kernel.

3.5.2 Filtro Gaussiano

O filtro Gaussiano pesa os pixels da vizinhança de acordo com uma função Gaussiana bidimensional:

\[ G(s,t) = \frac{1}{2\pi\sigma^2}\,e^{-\frac{s^2+t^2}{2\sigma^2}} \tag{3.13}\]

onde \(\sigma\) é o desvio padrão e controla o raio de influência. Pixels mais próximos do centro têm peso maior; pixels distantes são progressivamente ignorados.

Para visualizar o kernel antes de aplicá-lo, a Figura 3.13 exibe o kernel Gaussiano \(5\times5\) gerado para \(\sigma=1\) — note a queda radial dos pesos a partir do centro:

# Gera kernel Gaussiano 5×5 via cv2
k_gauss = cv2.getGaussianKernel(5, 1)
w_gauss = k_gauss @ k_gauss.T          # produto externo: G(s,t) = G(s)·G(t)
w_gauss = w_gauss / w_gauss.sum()      # garante soma = 1

print("Kernel Gaussiano 5×5 (σ=1), normalizado:")
for row in w_gauss:
    print("  " + "  ".join(f"{v:.4f}" for v in row))
print(f"\nSoma dos coeficientes: {w_gauss.sum():.6f}")
print(f"Peso central vs. canto: {w_gauss[2,2]:.4f} vs. {w_gauss[0,0]:.4f} "
      f"({w_gauss[2,2]/w_gauss[0,0]:.1f}× maior)")

mm.drawImagePlt((w_gauss * 1000).astype(np.uint8), scale=40)
Kernel Gaussiano 5×5 (σ=1), normalizado:
  0.0030  0.0133  0.0219  0.0133  0.0030
  0.0133  0.0596  0.0983  0.0596  0.0133
  0.0219  0.0983  0.1621  0.0983  0.0219
  0.0133  0.0596  0.0983  0.0596  0.0133
  0.0030  0.0133  0.0219  0.0133  0.0030

Soma dos coeficientes: 1.000000
Peso central vs. canto: 0.1621 vs. 0.0030 (54.6× maior)
Figura 3.13: Kernel Gaussiano 5×5 (σ=1): pesos normalizados, maiores no centro e decrescentes radialmente. A grade facilita a leitura de cada coeficiente.

A propriedade de separabilidade\(G(s,t) = G(s)\cdot G(t)\) — é computacionalmente valiosa: em vez de uma convolução 2D com \(n^2\) operações por pixel, aplica-se duas convoluções 1D com \(2n\) operações, reduzindo a complexidade de \(O(n^2)\) para \(O(n)\).

Comparado ao filtro de média, o Gaussiano:

  • Preserva melhor as bordas — a ponderação radial suaviza sem criar transições abruptas;
  • Não introduz anéis (ringing) no domínio da frequência, pois a Gaussiana é sua própria transformada de Fourier;
  • É controlado por \(\sigma\) — aumentar \(\sigma\) equivale a aumentar o raio de suavização de forma contínua e previsível.

A Figura 3.14 compara o filtro de média e o Gaussiano aplicados à imagem do mandrill com janela \(9\times9\):

w_media9 = np.ones((9,9), dtype=np.float32) / 81.0
img_media9 = mm.conv(img_gray, w_media9)
img_gauss9 = cv2.GaussianBlur(img_gray, (9,9), 0)

# Detalhe da região do olho
y0, y1, x0, x1 = 580, 740, 680, 900
crop = lambda img: img[y0:y1, x0:x1]

img_gray_crop = crop(img_gray)


mm.show(
    [crop(img_gray),  crop(img_media9),  crop(img_gauss9)],
    titles=["Detalhe: Original", "Média 9×9", "Gaussiano 9×9"],
    cols=3, figsize=(12, 8)
)
Figura 3.14: Comparação entre filtro de média e Gaussiano (janela 9×9, σ=0 para cálculo automático). O Gaussiano preserva melhor as bordas, visível no detalhe do rosto.

3.6 Filtragem Espacial de Realce

Os filtros de realce (sharpening filters) enfatizam transições abruptas de intensidade, aumentando a nitidez e a visibilidade de bordas. São filtros passa-alta — amplificam as componentes de alta frequência (bordas, textura) e suprimem as de baixa frequência (regiões uniformes).

A intuição é simples: se subtrairmos de uma imagem sua versão suavizada (que contém apenas as baixas frequências), o que resta são as altas frequências — bordas e detalhes. Somando esse resíduo de volta à imagem original, o contraste local aumenta:

\[ g = f + k\,(f - f_{\text{suave}}), \quad k > 0 \tag{3.14}\]

Os filtros de realce formalizam essa ideia diretamente no kernel, sem precisar de duas etapas separadas.

3.6.1 Laplaciano

O Laplaciano é um operador de segunda derivada isotrópico — responde igualmente a variações em todas as direções, ao contrário dos operadores de primeira derivada (Sobel, Prewitt) que são direcionais:

\[ \nabla^2 f = \frac{\partial^2 f}{\partial x^2} + \frac{\partial^2 f}{\partial y^2} \tag{3.15}\]

A segunda derivada tem uma propriedade fundamental para o realce: vale zero em regiões uniformes, é positiva antes de uma borda (intensidade crescente) e negativa após (intensidade decrescente). Subtrair o Laplaciano da imagem original, portanto, aumenta o contraste exatamente onde existem transições:

\[ g(x,y) = f(x,y) - \nabla^2 f(x,y) \tag{3.16}\]

Na forma discreta, a segunda derivada em \(x\) é aproximada por \(f(x+1,y) - 2f(x,y) + f(x-1,y)\), e analogamente em \(y\). Somando as duas direções, obtém-se o kernel \(w_4\) (4-vizinhos) ou \(w_8\) (8-vizinhos, incluindo diagonais):

\[ w_4 = \begin{bmatrix} 0 & 1 & 0 \\ 1 & -4 & 1 \\ 0 & 1 & 0 \end{bmatrix}, \qquad w_8 = \begin{bmatrix} 1 & 1 & 1 \\ 1 & -8 & 1 \\ 1 & 1 & 1 \end{bmatrix} \tag{3.17}\]

NotaSoma zero e centro negativo

Ambos os kernels têm soma dos coeficientes igual a zero: em regiões uniformes, a saída é 0 — o Laplaciano não altera o brilho médio, apenas detecta variações. O centro negativo indica que o pixel é comparado com seus vizinhos: quanto mais ele se destacar (para cima ou para baixo), maior o valor absoluto do Laplaciano naquele ponto.

No exemplo, o pixel central \([1,1] = 196\) tem vizinhos \(\{189, 190, 197, 197\}\). O Laplaciano retorna um valor próximo de zero porque a região é quase uniforme — o realce será mínimo. Em regiões de borda, onde os vizinhos diferem muito do centro, o Laplaciano retorna valores altos (positivos ou negativos), e a subtração em Equação 3.16 amplifica a transição.

A seguir aplica os dois kernels à imagem do mandrill completa, comparando a resposta bruta do Laplaciano com a imagem realçada:

patch  = img_gray[250:255, 250:255]
w4     = np.array([[0, 1, 0],
                   [1,-4, 1],
                   [0, 1, 0]], dtype=np.float32)
B      = np.ones((3,3), dtype=np.uint8)

p      = patch.astype(np.float32)
lap_11 = int(p[0,1] + p[1,0] - 4*p[1,1] + p[1,2] + p[2,1])
real_11 = int(np.clip(p[1,1] - lap_11, 0, 255))

print("Patch original:")
print(mm.drawImage(patch))
print(f"Laplac. w4 em  [1,1]: \
      {p[0,1]:.0f} + {p[1,0]:.0f} - 4×{p[1,1]:.0f} + {p[1,2]:.0f} + {p[2,1]:.0f} = {lap_11}")
print(f"Pixel realçado [1,1]: {p[1,1]:.0f} - ({lap_11}) = {real_11}")

# mm.drawImageKernel(patch, B, x=1, y=1, scale=20.0)
Patch original:
 95  97 103 113 115 
107 105 103 108 115 
 98 102 112 118 116 
 81  91 113 123 119 
 85  91 111 120 121 

Laplac. w4 em  [1,1]:       97 + 107 - 4×105 + 103 + 102 = -11
Pixel realçado [1,1]: 105 - (-11) = 116
Figura 3.15

No exemplo, o pixel \([1,1] = 196\) está em região quase uniforme — o Laplaciano retorna valor próximo de zero e o realce é mínimo. Em pixels de borda, onde os vizinhos diferem muito do centro, o Laplaciano retorna valores altos e a Equação 3.16 amplifica a transição significativamente.

A Figura 3.16 aplica os dois kernels à imagem do mandrill completa, comparando a resposta bruta com a imagem realçada:

w4 = np.array([[0, 1, 0],
               [1,-4, 1],
               [0, 1, 0]], dtype=np.float32)
w8 = np.array([[1, 1, 1],
               [1,-8, 1],
               [1, 1, 1]], dtype=np.float32)

def apply_lap(img, w):
    lap     = mm.conv(img, w).astype(np.float32) - 128
    lap_viz = np.clip(np.abs(lap) / np.abs(lap).max() * 255, 0, 255).astype(np.uint8)
    realce  = np.clip(img.astype(np.float32) - lap, 0, 255).astype(np.uint8)
    return lap_viz, realce

lap4_viz, realce4 = apply_lap(img_gray_crop, w4)
lap8_viz, realce8 = apply_lap(img_gray_crop, w8)

mm.show(
    [img_gray_crop, lap4_viz,       realce4,
     img_gray_crop, lap8_viz,       realce8],
    titles=["Original",  "Laplaciano w4",  "Realce w4",
            "Original",  "Laplaciano w8",  "Realce w8"],
    rows=2, cols=3, figsize=(14, 8)
)
Figura 3.16: Laplaciano aplicado à imagem do mandrill: resposta bruta (bordas detectadas) com w4 e w8, e imagens realçadas pela subtração do Laplaciano. w8 é mais sensível às diagonais.

3.6.2 Operador de Sobel

O operador de Sobel estima as derivadas parciais de primeira ordem nas direções horizontal e vertical. Diferente do Laplaciano (segunda derivada, isotrópico), o Sobel é direcional e mais robusto ao ruído, pois cada kernel combina uma derivada com uma suavização Gaussiana perpendicular:

\[ G_x = \begin{bmatrix} -1 & 0 & 1 \\ -2 & 0 & 2 \\ -1 & 0 & 1 \end{bmatrix} * f, \qquad G_y = \begin{bmatrix} -1 & -2 & -1 \\ 0 & 0 & 0 \\ 1 & 2 & 1 \end{bmatrix} * f \tag{3.18}\]

\(G_x\) detecta bordas verticais (variação na direção \(x\)); \(G_y\) detecta bordas horizontais (variação na direção \(y\)). Os pesos \(\{1,2,1\}\) na direção perpendicular correspondem à suavização Gaussiana 1D, que reduz a sensibilidade ao ruído.

NotaSobel é correlação, não convolução

Os kernels de Sobel são assimétricos — a rotação de 180° altera o resultado. cv2.Sobel implementa correlação cruzada (como cv2.filter2D). Para obter a derivada direcional correta, os sinais já estão definidos para correlação: \(G_x\) retorna valores positivos onde a intensidade cresce da esquerda para a direita.

A magnitude do gradiente combina os dois componentes, representando a força da borda independente de direção:

\[ |\nabla f| = \sqrt{G_x^2 + G_y^2} \tag{3.19}\]

E a direção do gradiente (perpendicular à borda) é:

\[ \theta = \arctan\left(\frac{G_y}{G_x}\right) \tag{3.20}\]

Para ilustrar numericamente, calculamos \(G_x\) e \(G_y\) manualmente no pixel central \([1,1]\) do patch 5×5:

patch = img_gray[250:255, 250:255]
p     = patch.astype(np.float32)

# Gx no pixel [1,1]: coluna direita - coluna esquerda (ponderada)
gx = (p[0,2] + 2*p[1,2] + p[2,2]) - (p[0,0] + 2*p[1,0] + p[2,0])

# Gy no pixel [1,1]: linha inferior - linha superior (ponderada)
gy = (p[2,0] + 2*p[2,1] + p[2,2]) - (p[0,0] + 2*p[0,1] + p[0,2])

mag   = np.sqrt(gx**2 + gy**2)
theta = np.degrees(np.arctan2(gy, gx))

print("Patch 5x5:")
print(mm.drawImage(patch))
print(f"Gx em [1,1]: ({p[0,2]:.0f} + 2*{p[1,2]:.0f} + {p[2,2]:.0f}) \
      - ({p[0,0]:.0f} + 2*{p[1,0]:.0f} + {p[2,0]:.0f}) = {gx:.1f}")
print(f"Gy em [1,1]: ({p[2,0]:.0f} + 2*{p[2,1]:.0f} + {p[2,2]:.0f}) \
      - ({p[0,0]:.0f} + 2*{p[0,1]:.0f} + {p[0,2]:.0f}) = {gy:.1f}")

# Substituído o | pelo prefixo mag para não quebrar o interpretador de tabelas do Quarto
print(f"mag(Grad f)  = sqrt({gx:.1f}^2 + {gy:.1f}^2) = {mag:.1f}")
print(f"Theta        = arctan({gy:.1f}/{gx:.1f}) = {theta:.1f} graus")
Patch 5x5:
 95  97 103 113 115 
107 105 103 108 115 
 98 102 112 118 116 
 81  91 113 123 119 
 85  91 111 120 121 

Gx em [1,1]: (103 + 2*103 + 112)       - (95 + 2*107 + 98) = 14.0
Gy em [1,1]: (98 + 2*102 + 112)       - (95 + 2*97 + 103) = 22.0
mag(Grad f)  = sqrt(14.0^2 + 22.0^2) = 26.1
Theta        = arctan(22.0/14.0) = 57.5 graus

O valor pequeno de \(|{\nabla f}|\) nesse patch confirma que a região é quase uniforme — o gradiente é alto apenas nas bordas reais da imagem. A Figura 3.17 aplica o operador à Mandrill completa, mostrando \(G_x\), \(G_y\) e a magnitude:

def norm255(img):
    mn, mx = img.min(), img.max()
    return ((img - mn) / (mx - mn + 1e-9) * 255).astype(np.uint8)

sobelx    = cv2.Sobel(img_gray_crop, cv2.CV_64F, 1, 0, ksize=3)
sobely    = cv2.Sobel(img_gray_crop, cv2.CV_64F, 0, 1, ksize=3)
magnitude = np.sqrt(sobelx**2 + sobely**2)
direcao   = np.degrees(np.arctan2(sobely, sobelx))

mm.show(
    [img_gray_crop, norm255(np.abs(sobelx)), norm255(np.abs(sobely)),
     norm255(magnitude), norm255(direcao % 180)],
    titles=["Original", "Gx (bordas vert.)", "Gy (bordas horiz.)",
            "Magnitude |∇f|", "Direção θ (mod 180°)"],
    cols=5
)
Figura 3.17: Operador de Sobel na imagem mandrill: Gx detecta bordas verticais, Gy detecta bordas horizontais, e a magnitude |∇f| combina ambos, revelando todas as bordas independente de direção.

3.6.3 Unsharp Masking (USM)

O Unsharp Masking é uma técnica clássica de realce de nitidez originária da fotografia analógica, hoje amplamente usada em software de edição de imagens. A ideia central é extrair as componentes de alta frequência da imagem (bordas e detalhes) e somá-las de volta à original com um peso \(k\):

Tabela 3.3: Etapas do Unsharp Masking.
Etapa Operação Descrição
1 \(\bar{f} = f * G_\sigma\) Suaviza com Gaussiana — retém baixas frequências
2 \(m = f - \bar{f}\) Máscara: diferença = altas frequências (bordas)
3 \(g = f + k \cdot m\) Soma ponderada da máscara à original

Substituindo a etapa 2 na etapa 3, obtém-se a expressão compacta:

\[ g = f + k\,(f - f*G_\sigma) = (1+k)\,f - k\,(f*G_\sigma) \tag{3.21}\]

O parâmetro \(k\) controla a intensidade do realce:

  • \(k = 0\): sem realce (\(g = f\));
  • \(k = 1\): USM clássico — duplica a contribuição das altas frequências;
  • \(k > 1\): High Boost Filtering — amplificação além do dobro, útil para imagens muito borradas.
AvisoAmplificação de ruído

O USM não distingue bordas de ruído — ambos são componentes de alta frequência. Para \(k\) elevado, o ruído presente na imagem é amplificado junto com as bordas. Por isso, é recomendável aplicar uma leve suavização antes do USM em imagens ruidosas, ou usar \(\sigma\) pequeno na Gaussiana.

Para ilustrar as três etapas, aplicamos o USM ao patch 30×30 com \(\sigma=1\) e \(k=1\):

patch  = img_gray_crop[35:65, 45:75]
p      = patch.astype(np.float32)
sigma, k = 1.0, 1.0

# Etapa 1: suavização Gaussiana
ksize   = int(6 * sigma + 1) | 1
p_suave = cv2.GaussianBlur(p, (ksize, ksize), sigma)

# Etapa 2: máscara de alta frequência
mascara = p - p_suave

# Etapa 3: realce
p_usm = np.clip(p + k * mascara, 0, 255).astype(np.uint8)

print(f"Pixel central [1,1]: original={int(p[1,1])} suave={p_suave[1,1]:.1f}"
      f" mascara={mascara[1,1]:.1f} realcado={p_usm[1,1]}")

mm.show(
    [patch,
     p_suave.astype(np.uint8),
     np.clip(mascara + 128, 0, 255).astype(np.uint8),
     p_usm],
    titles=["Patch original",
            f"Suavizado (σ={sigma})",
            "Máscara (alta freq., +128)",
            f"Realçado USM (k={k})"],
    cols=4, figsize=(12, 4)
)
Pixel central [1,1]: original=15 suave=20.2 mascara=-5.2 realcado=9

A Figura 3.18 aplica o USM à imagem do mandrill completa com diferentes valores de \(k\), evidenciando a progressão do realce:

def usm(img, sigma=1.0, k=1.0):
    ksize  = int(6 * sigma + 1) | 1
    suave  = cv2.GaussianBlur(img.astype(np.float32), (ksize, ksize), sigma)
    return np.clip(img.astype(np.float32) + k * (img.astype(np.float32) - suave),
                   0, 255).astype(np.uint8)

ks     = [0.5, 1.0, 2.0, 3.0]
imgs   = [img_gray_crop] + [usm(img_gray_crop, sigma=1.0, k=k) for k in ks]
titles = ["Original"] + [f"USM k={k}" for k in ks]

mm.show(imgs, titles=titles, cols=5)
Figura 3.18: Unsharp Masking na imagem do mandrill com σ=1 e k variando de 0.5 a 3.0. Para k>2 surgem artefatos nas bordas (halos) e o ruído de fundo começa a ser visível.

3.7 Filtros de Ordem: Filtro da Mediana

Os filtros de ordem (order-statistic filters) substituem o pixel central pelo valor de um percentil da distribuição de intensidades da vizinhança — ao contrário dos filtros lineares, que calculam combinações ponderadas. O mais importante é o filtro da mediana.

3.7.1 Ruído Impulsivo: Sal e Pimenta

O ruído sal e pimenta (salt-and-pepper noise) substitui pixels aleatórios por valores extremos: 0 (pimenta, preto) ou 255 (sal, branco). É comum em transmissão de imagens com erros de bit e em câmeras com sensores defeituosos.

Para entender por que os filtros lineares falham, considere uma vizinhança 3×3 onde um único pixel foi corrompido para 255:

\[ \text{vizinhança} = \begin{bmatrix} 102 & 98 & 105 \\ 100 & \mathbf{255} & 97 \\ 103 & 99 & 101 \end{bmatrix} \]

Tabela 3.4: Média vs. mediana com um pixel corrompido. A mediana ignora o outlier; a média é deslocada ~40 níveis.
Método Cálculo Resultado
Média \((102+98+\ldots+255+\ldots+101)/9\) \(\approx 140\)
Mediana \(\{97,98,99,100,\mathbf{101},102,103,105,255\}\) \(101\)
AvisoPor que filtros de média falham com ruído impulsivo?

A média é sensível a outliers — um único pixel com valor 255 em uma vizinhança de valor ≈ 100 eleva a saída para ≈ 140, espalhando o ruído pela imagem. A mediana, por ser um estimador robusto, seleciona o valor central da distribuição ordenada, descartando naturalmente os extremos sem nenhum ajuste especial.

O experimento na Figura 3.19 ilustra a adição de ruído com diferentes densidades e a degradação progressiva da imagem:

# Exemplo numérico: média vs. mediana com pixel corrompido
vizinhanca = np.array([102, 98, 105, 100, 255, 97, 103, 99, 101])
print(f"Vizinhança: {sorted([int(x) for x in vizinhanca])}")
print(f"Média:      {vizinhanca.mean():.1f}  (deslocada pelo outlier 255)")
print(f"Mediana:    {int(np.median(vizinhanca))}    (ignora o outlier)")
print(f"Valor original:  ~100")
Vizinhança: [97, 98, 99, 100, 101, 102, 103, 105, 255]
Média:      117.8  (deslocada pelo outlier 255)
Mediana:    101    (ignora o outlier)
Valor original:  ~100
def add_salt_pepper(img, prob=0.05, seed=42):
    """Adiciona ruído sal e pimenta com probabilidade total prob."""
    noisy = img.copy()
    rnd   = np.random.default_rng(seed).random(img.shape)
    noisy[rnd < prob / 2]     = 0    # pimenta
    noisy[rnd > 1 - prob / 2] = 255  # sal
    n_corrompidos = int((rnd < prob/2).sum() + (rnd > 1-prob/2).sum())
    return noisy, n_corrompidos

probs = [0.02, 0.05, 0.10]
imgs_noise, titles_noise = [img_gray_crop], ["Original"]

for p in probs:
    noisy, n = add_salt_pepper(img_gray_crop, p)
    imgs_noise.append(noisy)
    titles_noise.append(f"Ruído {int(p*100)}%\n({n:,} pixels)")

mm.show(imgs_noise, titles=titles_noise, cols=4)
Figura 3.19: Ruído sal e pimenta com densidades crescentes (2%, 5%, 10%). O parâmetro prob indica a fração total de pixels corrompidos, metade sal (255) e metade pimenta (0).

3.7.2 Filtro da Mediana

O filtro da mediana substitui cada pixel pelo valor mediano dos pixels da sua vizinhança \(n \times n\):

\[ g(x,y) = \text{med}_{(s,t) \in \mathcal{V}_{n}} \{f(x+s, y+t)\} \tag{3.22}\]

O valor mediano é aquele que ocupa a posição central quando os \(n^2\) valores da vizinhança são ordenados. Para uma janela \(3\times3\) (\(n^2=9\) pixels), a mediana é o 5º valor da sequência ordenada.

Para ilustrar, considere o mesmo patch 5×5 com um pixel corrompido artificialmente em \([1,1]\):

patch    = img_gray[250:255, 250:255].copy()
corrompido = patch.copy()
corrompido[1, 1] = 255   # injeta pixel sal no centro

# Vizinhança 3×3 centrada em [1,1]
viz_orig = sorted(patch[0:3, 0:3].ravel().tolist())
viz_corr = sorted(corrompido[0:3, 0:3].ravel().tolist())

print("Patch original:")
print(mm.drawImage(patch))
print("\nPatch com pixel corrompido [1,1]=255:")
print(mm.drawImage(corrompido))
print(f"\nVizinhança 3×3 original (ordenada):   {viz_orig}")
print(f"Mediana original:                       {viz_orig[4]}")
print(f"\nVizinhança 3×3 corrompida (ordenada): {viz_corr}")
print(f"Mediana corrompida:                     {viz_corr[4]}  ← ignora o 255")
print(f"Média  corrompida:                  {np.mean(viz_corr):.1f}  ← distorcida pelo 255")
Patch original:
 95  97 103 113 115 
107 105 103 108 115 
 98 102 112 118 116 
 81  91 113 123 119 
 85  91 111 120 121 


Patch com pixel corrompido [1,1]=255:
 95  97 103 113 115 
107 255 103 108 115 
 98 102 112 118 116 
 81  91 113 123 119 
 85  91 111 120 121 


Vizinhança 3×3 original (ordenada):   [95, 97, 98, 102, 103, 103, 105, 107, 112]
Mediana original:                       103

Vizinhança 3×3 corrompida (ordenada): [95, 97, 98, 102, 103, 103, 107, 112, 255]
Mediana corrompida:                     103  ← ignora o 255
Média  corrompida:                  119.1  ← distorcida pelo 255

O exemplo confirma: mesmo com o pixel corrompido a 255, a mediana retorna o valor central correto — o outlier ocupa a última posição na ordenação e é descartado naturalmente.

Por ser baseada em ordenação e não em soma, a mediana possui três propriedades fundamentais que a diferenciam dos filtros lineares:

  • Robusta ao ruído impulsivo — outliers vão para as extremidades da sequência ordenada e não afetam o valor central;
  • Preservadora de bordas — transições abruptas de intensidade são mantidas, pois a mediana seleciona um valor que já existe na vizinhança, sem criar novos níveis intermediários;
  • Não linear — não pode ser expressa como convolução, portanto mm.conv não se aplica; usa-se cv2.medianBlur.

A Figura 3.20 compara média e mediana aplicadas à imagem do mandrill com 10% de ruído sal e pimenta:

# 10% de ruído para evidenciar diferenças entre filtros
noisy_5, _ = add_salt_pepper(img_gray_crop, prob=0.1) 

# ── Filtros ───────────────────────────────────────────────────────────────────
f_gauss   = cv2.GaussianBlur(noisy_5, (5, 5), 1.0)
f_media   = mm.conv(noisy_5, np.ones((5, 5), dtype=np.float32) / 20.0)
f_median3 = cv2.medianBlur(noisy_5, 3)
f_median5 = cv2.medianBlur(noisy_5, 5)
f_bilat   = cv2.bilateralFilter(noisy_5, d=9, sigmaColor=75, sigmaSpace=75)

# filtros morfológicos no próximo capítulo, mas já adiantados aqui para comparação
# B         = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
# f_morf    = cv2.morphologyEx(           # apresentado no próximo capítulo
#                 cv2.morphologyEx(noisy_5, cv2.MORPH_OPEN,  B),
#                 cv2.MORPH_CLOSE, B)
B = mm.secross()  # elemento estruturante de caixa 3×3
f_morf     = mm.asf(noisy_5, 'OC', B)  # equivalente via morph

# ── MSE ───────────────────────────────────────────────────────────────────────
def mse(a, b):
    return float(np.mean((a.astype(np.float32) - b.astype(np.float32))**2))

print(f"{'Filtro':<22} {'MSE':>8}")
print("-" * 32)
pares = [("Gaussiano 5×5",    f_gauss),
         ("Média 5×5",        f_media),
         ("Mediana 3×3",      f_median3),
         ("Mediana 5×5",      f_median5),
         ("Bilateral",        f_bilat),
         ("Morf. open+close", f_morf)]
for nome, img in pares:
    print(f"{nome:<22} {mse(img_gray_crop, img):>8.2f}")

imgs   = [img_gray_crop, noisy_5,    f_gauss,        f_media,
          f_median3,     f_median5,  f_bilat,        f_morf]
titles = ["Original",    "Ruído 10%", "Gaussiano 5×5","Média 5×5",
          "Mediana 3×3", "Mediana 5×5", "Bilateral", "Morf. open+close"]

mm.show(
    imgs,
    titles=[f"Crop: {t}" for t in titles],
    cols=4, rows=2, figsize=(14, 8)
)
Filtro                      MSE
--------------------------------
Gaussiano 5×5            264.73
Média 5×5                876.54
Mediana 3×3               33.43
Mediana 5×5               70.11
Bilateral               1012.00
Morf. open+close          67.84
Figura 3.20: Comparação de filtros para remoção de ruído sal e pimenta (10%): Gaussiano, Média, Mediana, Bilateral e Morfológico. Linha superior: imagens completas; linha inferior: crop da região de interesse.

3.8 Aplicação Prática: Pré-processamento para Segmentação

Na prática, as técnicas deste capítulo raramente são usadas isoladamente. Um pipeline de pré-processamento típico combina várias etapas em sequência, adaptando-se ao tipo de imagem e à aplicação. A Figura 3.21 ilustra um pipeline completo:

  1. Equalização de histograma (CLAHE): normaliza o contraste independente das condições de iluminação;
  2. Filtro Gaussiano: suaviza ruído de aquisição sem destruir bordas;
  3. Detecção de bordas (Sobel/Canny): extrai estruturas relevantes para segmentação.
NotaOrdem importa

A ordem das operações afeta o resultado final. Em geral: (1) normalização de intensidade → (2) redução de ruído → (3) realce/segmentação. Inverter a ordem pode amplificar ruído ou perder bordas antes de detectá-las.

# Etapa 1: CLAHE
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
img_clahe = clahe.apply(img_gray_crop)

# Etapa 2: Gaussiano
img_gauss = cv2.GaussianBlur(img_clahe, (5, 5), 0)

# Etapa 3: Canny
edges = cv2.Canny(img_gauss, 50, 150)

# Comparação: pipeline vs. Canny direto
edges_direct = cv2.Canny(img_gray_crop, 50, 150)

mm.show(
    [img_gray_crop, img_clahe, img_gauss, edges, edges_direct],
    titles=["Original", "1. CLAHE", "2. Gaussiano", "3. Canny (pipeline)", "Canny (direto)"],
    cols=5
)
Figura 3.21: Pipeline de pré-processamento: CLAHE → Gaussiano → Canny. Cada etapa prepara a imagem para a seguinte, resultando em bordas limpas e bem definidas.

3.9 Resumo

Neste capítulo foram apresentadas as principais técnicas de processamento no domínio espacial, da manipulação direta de pixels até a filtragem por vizinhança:

  • Operações de ponto: aritméticas saturadas (mm.addm, mm.subm) e lógicas bit a bit (mm.band, mm.bor, mm.bnot) para recorte de ROI e combinação de imagens; alpha blending (mm.blend) para fusão ponderada com peso \(\alpha \in [0,1]\).
  • Histograma: função discreta de distribuição de intensidades; visualizado com mm.histImg e calculado com mm.hist; base para diagnóstico tonal e para as técnicas de equalização e especificação.
  • Equalização: redistribuição automática das intensidades pela CDF (mm.equalize), com variante adaptativa CLAHE para controle local do contraste.
  • Especificação de histograma: transferência do perfil tonal de uma imagem de referência via mapeamento inverso da CDF — generalização da equalização para distribuições arbitrárias.
  • Correlação e convolução: mecanismo de janela deslizante implementado em mm.conv (cv2.filter2D); diferenciados pela rotação de 180° do kernel — relevante apenas para kernels assimétricos.
  • Filtros de suavização: média (kernel uniforme, borra bordas proporcionalmente ao tamanho) e Gaussiano (ponderação radial, separável, sem ringing, preserva melhor as bordas).
  • Filtros de realce: Laplaciano (\(w_4\)/\(w_8\), segunda derivada isotrópica), Sobel (gradiente direcional de primeira ordem, com magnitude \(|\nabla f|\) e direção \(\theta\)) e Unsharp Masking (amplificação das altas frequências com parâmetro \(k\)).
  • Filtro da mediana: não linear, robusto a outliers, preserva bordas — superior aos filtros lineares para ruído sal e pimenta.
  • Pipeline prático: encadeamento CLAHE → Gaussiano → Canny como estratégia de pré-processamento; mm.drawImageKernel para visualização didática da janela deslizante.

O Capítulo 4 abordará o processamento no domínio da frequência (Transformada de Fourier) e a morfologia matemática (erosão, dilatação, abertura, fechamento) — onde as funções mm.ero e mm.dil da biblioteca morph.py serão exploradas em profundidade.

3.10 🤖 Uso do NotebookLM como Tutor Complementar

Nesta edição, incentivamos o uso do NotebookLM como ferramenta complementar de aprendizagem. Essa ferramenta de IA utiliza exclusivamente os documentos fornecidos pelo autor como base de conhecimento, garantindo respostas coerentes com o conteúdo do livro — incluindo as funções da biblioteca morph.py e os experimentos realizados neste capítulo.

Para cada capítulo, preparamos um projeto específico na plataforma com o PDF do capítulo, os notebooks e materiais auxiliares. Sugerimos explorar especialmente:

  • Guia de Estudo: resumo estruturado dos conceitos, ideal para revisão antes de provas;
  • Conversa: tire dúvidas sobre equalização, convolução, filtros e pipelines diretamente com o tutor;
  • Perguntas frequentes: questões típicas sobre a diferença entre média e mediana, USM, Laplaciano vs. Sobel.
Importante🎓 Estude com o Tutor Inteligente

Para interagir com o conteúdo deste capítulo, acesse o link a seguir. O ambiente contém materiais didáticos em diferentes formatos, gerados a partir do PDF do capítulo. Na plataforma, explore especialmente as opções Guia de Estudo e Conversa para aprofundar sua compreensão.

🚀 ACESSAR NOTEBOOKLM: CAPÍTULO 03

3.11 Lista de Exercícios

  1. (10%) Explique a diferença entre convolução e correlação cruzada. Para quais tipos de kernel os resultados são idênticos? Dê um exemplo de kernel assimétrico (como Sobel \(G_x\)) e mostre numericamente que os resultados diferem aplicando-o ao patch 5×5 do capítulo das duas formas.

  2. (15%) Considere uma imagem 5×5 com intensidades concentradas entre os níveis 3 e 5 (baixo contraste, 3 bits). Aplique manualmente o algoritmo de equalização da Tabela 3.1, preenchendo todas as colunas da tabela (\(k\), \(h[k]\), \(p[k]\), \(\text{cdf}[k]\), \(\text{lut}[k]\)). Verifique o resultado com mm.equalize.

  3. (15%) Usando mm.conv, aplique o filtro de média com kernels de tamanho 3×3, 9×9 e 21×21 à imagem do mandrill. Para cada versão, calcule o PSNR (Peak Signal-to-Noise Ratio) em relação à original: \[\text{PSNR} = 10\log_{10}\!\left(\frac{255^2}{\text{MSE}}\right), \quad \text{MSE} = \frac{1}{MN}\sum_{i,j}(f-g)^2\] Plote o PSNR em função do tamanho do kernel e explique o que a queda progressiva indica sobre a relação entre suavização e perda de informação.

  4. (15%) Usando add_salt_pepper com densidade de 5%, aplique e compare: (a) mm.conv com média 3×3, (b) cv2.GaussianBlur com \(\sigma=1\), (c) cv2.medianBlur com janela 3×3 e (d) cv2.medianBlur com janela 5×5. Exiba as imagens com mm.show em grade 2×4 (linha 1: imagens, linha 2: histogramas via mm.histImg). Explique por que a mediana supera os filtros lineares usando o argumento da Tabela 3.4.

  5. (15%) Implemente mm.conv0 usando apenas operações NumPy vetorizadas — sem laços Python e sem cv2.filter2D — com o operador de stride tricks (np.lib.stride_tricks.sliding_window_view). Compare o resultado e o tempo de execução com mm.conv0 (laços) e mm.conv (cv2) para kernels 3×3 e 15×15 na imagem do mandrill.

  6. (15%) Aplique o Unsharp Masking com \(\sigma=1\) e \(k \in \{0.5, 1.0, 2.0, 4.0\}\) usando a função usm do capítulo. Para cada valor de \(k\): (a) calcule a diferença absoluta \(|g - f|\), (b) exiba as imagens e as diferenças com mm.show, e (c) plote o histograma das diferenças com mm.histImg. Identifique a partir de qual \(k\) os artefatos (halos e amplificação de ruído) tornam-se visualmente inaceitáveis.

  7. (15%) Escolha uma imagem de raio-X ou tomografia disponível publicamente (ex.: via mm.read de URL) e projete um pipeline de pré-processamento com pelo menos 4 etapas sequenciais, justificando cada escolha com base nos conceitos do capítulo. Exiba com mm.show em grade: imagem original, cada etapa intermediária e o resultado final com seus histogramas (mm.histImg).

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 operações de intensidade, histograma, convolução e filtragem espacial.
  • Szeliski (2022) para a visão computacional e aplicações práticas de filtragem.
  • Bradski; Kaehler (2008) para a implementação prática com OpenCV e morph.py.

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


🎯 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.0 | TestSuite: 1.1.0

Executando os Testes

Para rodar os testes, execute TestSuite("EP01_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.

Exemplo de teste de sqrt em Python, com timeit isolando cada operação:

🎮 Simulador: arraste os pontos ou use os controles ⚡ Euclidiana (√) ≈ mais custosa
📐 Euclidiana (L2)
5.00
√(Δx²+Δy²)
🧱 City‑Block (L1)
7.00
|Δx|+|Δy|
🏁 Chessboard (L∞)
4.00
max(|Δx|,|Δy|)
👆 clique e arraste os pontos A (roxo) ou B (laranja)
Ponto A
Ponto B
💡 O gráfico mostra: linha reta tracejada (Euclidiana), caminho em L (City‑Block) e o retângulo (Chessboard).
Euclidiana City‑Block Chessboard
Figura 3.22: Simulador: Distâncias Euclidiana, City-block e Chessboard
%%writefile EP02_01.py
# Código Python
x1,y1,x2,y2 = int(input()), int(input()), int(input()), int(input())
# Cálculo das diferenças
dx = abs(x2 - x1)
dy = abs(y2 - y1)

# 1. Distância Euclidiana (L2)
dist_euclidiana = (dx**2 + dy**2)**0.5

# 2. Distância City-block / Manhattan (L1)
dist_city_block = dx + dy

# 3. Distância Chessboard / Chebyshev (Linf)
dist_chessboard = max(dx, dy)

# Saída formatada conforme os casos de teste
print(f"{dist_euclidiana:.2f}")
print(f"{dist_city_block:.2f}")
print(f"{dist_chessboard:.2f}")
Writing EP02_01.py
import numpy as np
np.set_printoptions(linewidth=100, edgeitems=3, threshold=1000)
print("Substitui emoji Unicode literal ✅ por comando")
Substitui emoji Unicode literal ✅ por comando
import numpy as np
print(mm.drawImage(np.zeros((5, 5))))
  0   0   0   0   0 
  0   0   0   0   0 
  0   0   0   0   0 
  0   0   0   0   0 
  0   0   0   0   0 
TestSuite("EP02_01.py").run()
✔️ EP02_01.cases já existe em casos/
📋 8 caso(s) carregado(s) de casos/EP02_01.cases

🔍 Testando Python: EP02_01.py
❌ Caso1_Exemplo_Enunciado: FALHOU
   📥 Entrada:
1
4
1.5 -30
0 100 180 255
   🎯 Esperado:
0 120 240 255

####
   📤 Obtido:

❌ Caso2_Aumento_Brilho_Sem_Contraste: FALHOU
   📥 Entrada:
2
2
1.0 50
0 50
100 255
   🎯 Esperado:
50 100
150 255

####
   📤 Obtido:

❌ Caso3_Reducao_Brilho_Saturacao_Preto: FALHOU
   📥 Entrada:
1
3
1.0 -100
0 50 150
   🎯 Esperado:
0 0 50

####
   📤 Obtido:

❌ Caso4_Aumento_Contraste_Puro: FALHOU
   📥 Entrada:
1
3
2.0 0
10 128 200
   🎯 Esperado:
20 255 255

####
   📤 Obtido:

❌ Caso5_Reducao_Contraste_Efeito_Cinza: FALHOU
   📥 Entrada:
1
4
0.5 0
0 100 200 255
   🎯 Esperado:
0 50 100 128

####
   📤 Obtido:

❌ Caso6_Inversao_Simulada: FALHOU
   📥 Entrada:
1
3
-1.0 255
0 128 255
   🎯 Esperado:
255 127 0

####
   📤 Obtido:

❌ Caso7_Arredondamento_Especifico: FALHOU
   📥 Entrada:
1
2
1.2 5
10 20
   🎯 Esperado:
17 29

####
   📤 Obtido:

❌ Caso8_Matriz_Nula_Alpha_Zero: FALHOU
   📥 Entrada:
2
2
0.0 128
0 255
100 200
   🎯 Esperado:
128 128
128 128
   📤 Obtido:


📊 Resultado: 0/8 (0.0%)
from IPython.display import display, HTML
display(HTML("<h1>oi</h1>"))

oi