9  Aprendizado Profundo para Visão Computacional

Os capítulos anteriores estabeleceram os fundamentos da Visão Computacional (VC) por meio de métodos clássicos de extração e representação de características. No Capítulo 7, descritores como Local Binary Patterns (LBP) e Histogram of Oriented Gradients (HOG) mostraram como texturas e formas podem ser codificadas por descritores projetados manualmente. No Capítulo 8, algoritmos como Oriented FAST and Rotated BRIEF (ORB) e o detector Haar Cascade estenderam esse princípio a tarefas de correspondência, detecção e reconhecimento de objetos.

Essas técnicas permanecem relevantes por sua interpretabilidade e eficiência computacional, mas dependem de uma etapa prévia de definição manual de descritores, denominada engenharia de características (feature engineering). Essa dependência limita a adaptação do modelo a cenários para os quais o descritor não foi projetado.

O Aprendizado Profundo (Deep Learning) propõe uma alternativa: em vez de especificar manualmente as características relevantes, o modelo aprende automaticamente representações a partir dos dados durante o treinamento — processo conhecido como aprendizado de representações (representation learning) (Goodfellow; Bengio; Courville, 2016). Na VC, essa estratégia é implementada principalmente pelas Redes Neurais Convolucionais (Convolutional Neural Networks — CNNs), nas quais os filtros convolucionais deixam de ter coeficientes fixos e passam a ser ajustados por algoritmos de otimização.

Ainda que representem uma mudança na construção de sistemas de reconhecimento de padrões, as CNNs preservam conceitos já estudados neste livro: a convolução, apresentada no Capítulo 3, permanece a operação responsável pela extração local de características, agora aplicada com coeficientes aprendidos em vez de projetados.

Vale ressaltar que o objetivo deste capítulo não é explorar exaustivamente a teoria do Aprendizado Profundo, mas sim oferecer uma visão geral de seus fundamentos e demonstrar como essas arquiteturas são aplicadas no contexto da VC. Leitores interessados em um aprofundamento teórico e conceitual na área devem recorrer a referências especializadas da literatura, como Goodfellow; Bengio; Courville (2016) e LeCun; Bengio; Hinton (2015).

9.1 Objetivos do Capítulo

Ao final deste capítulo, o estudante deverá ser capaz de:

  • Relacionar a convolução aprendida pelas CNNs com a convolução de kernels fixos apresentada no Capítulo 3;
  • Descrever a arquitetura básica de uma CNN e a função de suas camadas principais;
  • Implementar, treinar e avaliar modelos de CNN para classificação de imagens;
  • Aplicar transferência de aprendizado (transfer learning) para adaptar modelos pré-treinados a novos problemas;
  • Utilizar modelos pré-treinados em tarefas de classificação, detecção de objetos e segmentação;
  • Implementar, treinar e avaliar uma arquitetura U-Net para segmentação semântica, comparando-a a abordagens clássicas;
  • Preparar conjuntos de dados anotados e integrá-los a um pipeline de treinamento por meio de plataformas como o Roboflow;
  • Integrar geometria computacional e Aprendizado Profundo em aplicações de realidade aumentada e fotogrametria.

A Figura 9.1 sintetiza a organização dos conceitos estudados neste capítulo e as relações entre eles.

Figura 9.1: Visão geral dos principais conceitos abordados neste capítulo. Fonte: elaborado com auxílio do Gemini Notebook (Google, 2025).

9.2 Panorama: Classificação, Detecção e Segmentação

As tarefas de VC diferenciam-se, sobretudo, pela informação produzida como saída. A classificação atribui um único rótulo à imagem inteira; a detecção de objetos localiza e identifica os objetos presentes na cena; a segmentação associa uma classe a cada pixel e, em algumas abordagens, distingue diferentes instâncias de uma mesma categoria.

A Tabela 9.1 resume as tarefas estudadas ao longo do livro, indicando a pergunta que cada uma responde e a granularidade da informação produzida.

Tabela 9.1: Comparativo entre as principais tarefas de VC segundo a granularidade da informação produzida.
Tarefa Pergunta respondida Granularidade da saída Capítulo
Classificação “Qual é a classe desta imagem?” Um único rótulo para a imagem inteira 7 e 9
Detecção de objetos “Quais objetos existem e onde estão?” Classe e caixa delimitadora (bounding box) para cada objeto 8 e 9
Segmentação semântica “A que classe pertence cada pixel?” Um rótulo de classe para cada pixel 8 e 9
Segmentação de instâncias “Quais pixels pertencem a cada objeto?” Um rótulo por pixel para cada instância 8 e 9
Segmentação panóptica “Qual é a classe e a identidade de cada objeto?” Classe e identificador de instância para cada pixel 8 e 9

Essas tarefas representam níveis crescentes de interpretação da imagem: a classificação descreve a cena de forma global, a detecção acrescenta a localização dos objetos e a segmentação produz uma representação espacial detalhada, permitindo analisar cada região individualmente. Este capítulo concentra-se, primeiro, na classificação por CNNs, para em seguida estender os mesmos princípios à detecção e à segmentação.

9.3 Configuração do Ambiente

Os exemplos deste capítulo utilizam o PyTorch, um framework amplamente empregado no desenvolvimento e no treinamento de modelos de Aprendizado Profundo. O código a seguir verifica a disponibilidade das bibliotecas necessárias e instala, automaticamente, aquelas ainda não presentes no ambiente de execução.

Caso o PyTorch não esteja instalado, é selecionada automaticamente uma versão compatível com o hardware disponível: a versão com suporte a CUDA, se houver GPU NVIDIA disponível, ou a versão para execução em CPU, caso contrário.

Em seguida, o ambiente é inicializado com a importação das bibliotecas utilizadas ao longo do capítulo, a definição de uma semente aleatória para favorecer a reprodutibilidade dos experimentos e a obtenção do arquivo morph.py — a biblioteca didática de processamento morfológico já utilizada em capítulos anteriores —, caso ele ainda não esteja disponível no diretório de trabalho.

import contextlib, importlib, importlib.metadata, importlib.util
import io, os, random, shutil, subprocess, sys, urllib.request, warnings

# Suprime avisos do PyTorch e warnings gerais
warnings.filterwarnings("ignore", category=UserWarning)

url = ("https://raw.githubusercontent.com/fzampirolli/"
       "pdi-vc/master/morph/config.py")
if not os.path.exists("config.py"):
    urllib.request.urlretrieve(url, "config.py")

import config

# Silencia stdout e stderr tanto em nível de Python quanto de File Descriptors do SO
def setup_silencioso():
    with open(os.devnull, "w") as fnull:
        old_out = os.dup(1)
        old_err = os.dup(2)
        try:
            os.dup2(fnull.fileno(), 1)
            os.dup2(fnull.fileno(), 2)
            with contextlib.redirect_stdout(fnull), contextlib.redirect_stderr(fnull):
                config.setup()
        finally:
            os.dup2(old_out, 1)
            os.dup2(old_err, 2)
            os.close(old_out)
            os.close(old_err)

setup_silencioso()
from morph import mm


def setup_cap09():
    """Instala libs ausentes p/ este capítulo de forma 100% silenciosa."""
    pkgs = {
        "skimage": "scikit-image", "numpy": "numpy",
        "sklearn": "scikit-learn", "matplotlib": "matplotlib",
        "torchviz": "torchviz", "ultralytics": "ultralytics",
        "roboflow": "roboflow",
    }
    for mod, pkg in pkgs.items():
        if importlib.util.find_spec(mod) is None:
            subprocess.run([sys.executable, "-m", "pip", "install", "-q", pkg],
                           stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

    if importlib.util.find_spec("torch") is None:
        args = (["torch", "torchvision"] if shutil.which("nvidia-smi")
                 else ["--index-url",
                       "https://download.pytorch.org/whl/cpu",
                       "torch", "torchvision"])
        subprocess.run([sys.executable, "-m", "pip", "install", "-q", *args],
                       stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

    if not shutil.which("dot") and shutil.which("apt-get"):
        subprocess.run(["apt-get", "install", "-y", "-qq", "graphviz"],
                       stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)


setup_cap09()

import cv2, numpy as np, torch
import torch.nn as nn
import torch.optim as optim
import matplotlib.pyplot as plt
import torchvision.models as models
import torchvision.transforms as T
from PIL import Image
from skimage import data as skdata
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
from torch.utils.data import DataLoader, TensorDataset
from torchvision.datasets import OxfordIIITPet
from torchvision.models.detection import (
    fasterrcnn_resnet50_fpn, FasterRCNN_ResNet50_FPN_Weights)
from torchvision.models.segmentation import deeplabv3_resnet50
from torchvision.transforms.functional import to_tensor
from torchviz import make_dot
from ultralytics import YOLO

torch.manual_seed(42)
FLAG_LIMPAR_DADOS = False
device = "cuda" if torch.cuda.is_available() else "cpu"
gpu = f" ({torch.cuda.get_device_name(0)})" if device == "cuda" else ""
ver = importlib.metadata.version("ultralytics")
print(f"✅ Ambiente pronto. OpenCV {cv2.__version__} | "
      f"morph {getattr(mm, '__version__', 'local_file')} | "
      f"PyTorch {torch.__version__} | Ultralytics {ver} | {device}{gpu}")
✅ Ambiente pronto. OpenCV 5.0.0 | morph local_file | PyTorch 2.6.0+cu124 | Ultralytics 8.4.113 | cuda (NVIDIA GeForce GTX TITAN X)

9.4 Fundamentos de Aprendizado Profundo para VC

As CNNs são a principal arquitetura de Aprendizado Profundo aplicada à análise de imagens. Seu funcionamento baseia-se na composição de operações convolucionais organizadas em camadas sucessivas, nas quais os filtros aprendidos durante o treinamento transformam a imagem em representações progressivamente mais abstratas. Nesta seção, são apresentados os conceitos fundamentais que conectam a convolução espacial estudada anteriormente aos modelos modernos de VC, incluindo a extração hierárquica de características, o processo de treinamento e a utilização de modelos pré-treinados.

9.4.1 Da Convolução Fixa à Convolução Aprendida

O Capítulo 3 apresentou a convolução espacial com kernels fixos, como os operadores de Sobel, projetados para realçar características específicas de uma imagem. Nos Capítulos 7 e 8, o mesmo princípio sustentou descritores como HOG, LBP e ORB, além do detector Haar Cascade: em todos esses casos, os filtros são definidos antes da execução do algoritmo e permanecem inalterados durante o processamento.

A Figura 9.2 retoma o funcionamento da convolução espacial: o simulador permite selecionar diferentes kernels e acompanhar o deslocamento da janela de convolução sobre uma imagem. Em cada posição, os coeficientes do kernel combinam-se com a vizinhança local da imagem — denominada campo receptivo (receptive field) — para produzir um valor do mapa de características (feature map), ilustrando também o compartilhamento de pesos (weight sharing)

As CNNs preservam essa operação, mas substituem kernels fixos por filtros aprendidos: em vez de coeficientes definidos previamente, a rede ajusta esses valores durante o treinamento a partir de exemplos rotulados, buscando minimizar uma função de perda (loss function), que mede a diferença entre as previsões do modelo e as respostas esperadas.

A diferença essencial entre os métodos clássicos e as CNNs, portanto, não está na operação de convolução em si, mas na forma como os filtros são obtidos: enquanto os primeiros utilizam filtros projetados manualmente, as CNNs aprendem, a partir dos dados de treinamento, representações adequadas à tarefa.

🎯 Simulador: Operação de Convolução 2D Clássica Entrada 12×12 · Kernel 3×3 · Stride 1
IMAGEM DE ENTRADA (12×12)
FILTRO (KERNEL 3×3)
Posição Atual: (0, 0) [Saída 10×10]
Entrada (12×12)
Kernel (3×3)
Mapa de Saída (10×10)
∑ (xᵢ × wᵢ) = cálculo da posição atual...
Figura 9.2: Simulador interativo de convolução 2D clássica: escolha entre as três imagens sintéticas de entrada 12×12 (casa, rosto feliz ou triste) e um filtro 3×3 (Sobel V, Sobel H, Nitidez ou Identidade) e avance passo a passo para observar como os produtos internos locais do campo receptivo constroem o mapa de características célula a célula.

Para compreender como esse aprendizado ocorre, é necessário estudar a unidade básica de processamento das redes neurais: o neurônio artificial.

9.4.2 Neurônio Artificial

O neurônio artificial (artificial neuron) é a unidade fundamental de processamento de uma rede neural. Seu primeiro modelo matemático — um conjunto de entradas combinadas e comparadas a um limiar — foi proposto por McCulloch; Pitts (1943), ainda sem qualquer mecanismo de aprendizado. O Perceptron (Rosenblatt, 1958) avançou sobre essa formulação ao introduzir uma regra de ajuste dos pesos a partir de exemplos, tornando-se o primeiro modelo de neurônio artificial capaz de aprender e a base das arquiteturas modernas de Aprendizado Profundo (Deep Learning). O termo Aprendizado Profundo refere-se ao uso de redes com múltiplas camadas de processamento, capazes de aprender representações hierárquicas dos dados: as primeiras camadas aprendem características simples, como bordas e texturas, e as camadas mais profundas combinam progressivamente essas representações para identificar estruturas e objetos mais complexos.

Cada neurônio recebe um conjunto de entradas, calcula uma combinação linear desses valores e aplica uma função de ativação (activation function), produzindo um único valor de saída. Matematicamente, a combinação linear é dada por

\[ z=\sum_{i=1}^{n}w_i x_i+b, \]

em que \(x_i\) representam as entradas, \(w_i\) os pesos associados a cada entrada e \(b\) o viés (bias). A saída do neurônio é obtida pela aplicação da função de ativação:

\[ y=f(z). \]

Nas CNNs, esse princípio assume formas distintas conforme a camada. Nas camadas convolucionais (convolutional layers), cada neurônio processa apenas uma pequena região da entrada, denominada campo receptivo (receptive field), preservando a organização espacial da imagem. Nas camadas totalmente conectadas (fully connected layers), cada neurônio recebe todas as saídas da camada anterior, combinando as características extraídas para produzir a saída final da rede, como a classe atribuída à imagem.

A Figura 9.3 ilustra o funcionamento de um neurônio artificial: o simulador permite modificar as entradas (\(x_1\) e \(x_2\)), os pesos (\(w_1\) e \(w_2\)), o viés (\(b\)) e a função de ativação, observando em tempo real o cálculo da combinação linear e da saída correspondente.

⚙️ Simulador: Neurônio Artificial em uma CNN y = f(∑ wᵢxᵢ + b)
x₁ w₁
x₂ w₂
b
Os valores de x variam de -3 a 3 porque, em uma CNN, os pixels (0–255) são normalizados antes de entrar na rede. O desenho ao lado traduz esse valor normalizado de volta em um tom de cinza, só para dar intuição visual — os números que valem para a conta são os das barras.
Campo Receptivo → Convolução → Mapa de Características
Ativação em f(z)
x₁, x₂ = intensidade dos pixels no campo receptivo · w₁, w₂ = pesos do kernel (filtro) · z = resultado da convolução nesta posição · y = valor do pixel produzido no mapa de características, após a ativação.
z = (1.00 × 0.80) + (-1.50 × 0.50) + 0.20 = 0.25 → y = 0.25
Figura 9.3: Simulador interativo do Neurônio Artificial em contexto de CNN: alterne entre um neurônio de camada convolucional (onde x_i são intensidades de pixel em um campo receptivo e w_i são pesos do kernel) e um neurônio de camada totalmente conectada, ajustando entradas, pesos, viés e função de ativação para visualizar o cálculo de z e da saída y em tempo real.

Em uma CNN, milhares de neurônios organizam-se em camadas com funções específicas: as primeiras são responsáveis pela extração de características por meio da convolução, e as últimas realizam a classificação a partir das características aprendidas.

9.4.3 Camada Convolucional

A camada convolucional (convolutional layer) é responsável pela extração de características da imagem. Cada filtro gera um mapa de características (feature map), cuja intensidade em cada posição indica a resposta do filtro à região correspondente da entrada.

A operação realizada segue o mesmo princípio de deslocamento e combinação local apresentado no Capítulo 3 para a convolução espacial. Considerando um kernel \(K\) de dimensão \(k \times k\), o valor produzido na posição \((i,j)\) é dado por

\[ F(i,j)=\sum_{u=0}^{k-1}\sum_{v=0}^{k-1}K(u,v)\,I(i+u,j+v). \]

Vale registrar uma distinção terminológica: a expressão acima corresponde, formalmente, a uma correlação cruzada (cross-correlation), e não à convolução matemática estrita, que exige a reflexão do kernel antes da combinação. A maioria dos frameworks de Aprendizado Profundo, incluindo o PyTorch, implementa essa operação sem reflexão e a designa, por convenção, como convolução — convenção adotada também neste capítulo. Essa diferença não tem efeito prático sobre o treinamento, uma vez que os coeficientes do kernel são aprendidos e não impostos previamente.

A principal diferença em relação aos métodos clássicos está, assim, na obtenção do kernel \(K\): em filtros tradicionais, seus coeficientes são definidos manualmente para destacar características específicas da imagem; nas CNNs, os coeficientes são inicializados automaticamente e ajustados durante o treinamento por meio da retropropagação do erro (backpropagation), o que torna cada filtro especializado em identificar padrões relevantes para a tarefa em estudo.

Dois conceitos caracterizam essa camada:

  • Compartilhamento de pesos (weight sharing): o mesmo filtro é aplicado em todas as posições da imagem, reduzindo significativamente o número de parâmetros do modelo.
  • Campo receptivo (receptive field): cada neurônio convolucional processa apenas uma pequena vizinhança da imagem, preservando a estrutura espacial dos dados.

Ao empilhar várias camadas convolucionais, a rede aprende uma hierarquia de características: as primeiras camadas tendem a detectar padrões simples, como bordas e texturas, e as camadas mais profundas combinam essas informações para representar estruturas progressivamente mais complexas. Após a convolução, o mapa de características é submetido a uma função de ativação, introduzindo não linearidade no modelo e ampliando sua capacidade de representar relações complexas entre as variáveis de entrada.

A Figura 9.4 apresenta essa camada de forma interativa.

⚙️ Simulador: Operação de Convolução & Mapa de Características F(i,j) = f(∑ K(u,v) · I(i+u, j+v))
IMAGEM DE ENTRADA
KERNEL (FILTRO FIXO)
Zero-Padding (p = 1)
pixel real margem fixa da imagem (0) padding do algoritmo (0)
O mesmo kernel desliza sobre toda a imagem reutilizando seus coeficientes (compartilhamento de pesos). Cada imagem já vem cercada por uma margem fixa de 1 pixel de fundo (zeros, contorno tracejado âmbar), isolando a forma nos quatro lados. Escolha uma imagem e um kernel fixo acima, depois use ◀ ▶ ou "Auto" para percorrer o campo receptivo — o Feature Map à direita é preenchido célula a célula, na mesma ordem em que a convolução é calculada (as células ainda não visitadas aparecem como "···").
📌 A saída F(i,j) vem do campo receptivo entre (i,j) e (i+2,j+2); seu centro real é (i+1,j+1) — 1 linha e 1 coluna abaixo/à direita do índice usado para rotular a célula, sempre nas duas direções. Esse deslocamento só fica visível no eixo em que o kernel diferencia a imagem (por isso o Sobel V parece deslocar só para o lado, e o Sobel H, só para baixo).
Ativação em f(z)
🔍 Cálculo Detalhado no Campo Receptivo Atual
z = 0.00 → y = ReLU(z) = 0.00
Figura 9.4: Simulador interativo da Camada Convolucional: escolha entre três imagens de entrada 12×12 (casa, rosto feliz ou rosto triste) para observar como os mesmos kernels fixos reagem a diferentes bordas e formas. Navegue pelo campo receptivo com os botões ou reprodução automática, ajuste a função de ativação e alterne o zero-padding, acompanhando o mapa de características sendo revelado célula a célula, com o cálculo detalhado termo a termo e a fórmula da dimensão de saída em tempo real.

9.4.4 Função de Ativação

A convolução é uma operação linear. Para que a rede possa modelar relações não lineares entre entradas e saídas, aplica-se uma função de ativação (activation function) após cada camada convolucional.

A função mais utilizada em CNNs é a ReLU (Rectified Linear Unit), definida por

\[ \mathrm{ReLU}(x)=\max(0,x). \]

Essa função preserva os valores positivos e substitui por zero os valores negativos, introduzindo não linearidade no modelo e favorecendo o treinamento de redes profundas com baixo custo computacional.

A Figura 9.5 ilustra o funcionamento da ReLU aplicada tanto a valores individuais quanto a um mapa de características, permitindo comparar a saída antes e depois da ativação.

⚡ Simulador: Função de Ativação ReLU ReLU(x) = max(0, x)
x = x = -2.50 → ReLU(x) = 0.00
Curva da função ReLU
Mapa de características: antes / depois
Figura 9.5: Simulador interativo da função de ativação ReLU: arraste o controle para ver como valores negativos são zerados e valores positivos são preservados, tanto na curva quanto em um mapa de características real.

Os mapas de características resultantes da convolução e da ativação preservam a estrutura espacial da imagem. Em muitas arquiteturas, a etapa seguinte reduz sua resolução por meio de uma operação de pooling.

9.4.5 Pooling

A camada de pooling reduz a resolução espacial dos mapas de características, preservando as informações mais relevantes para as etapas seguintes do processamento. A operação mais utilizada é o max-pooling, que seleciona o maior valor em cada janela da imagem:

\[ P(i,j)=\max_{(u,v)\in\text{janela}(i,j)}F(u,v). \]

Essa redução diminui o custo computacional das camadas subsequentes e torna a representação mais robusta a pequenas variações na posição dos padrões presentes na imagem.

A Figura 9.6 apresenta essa operação sobre um mapa de características de 8×8 pixels, reduzido para 4×4 por janelas de 2×2 com passo igual a 2, alternando entre max-pooling e average-pooling — que calcula, em vez do máximo, a média dos valores da janela correspondente.

🔻 Simulador: Pooling janela 2×2, stride 2
Tipo:
Janela atual: (0, 0) de 4×4
O max-pooling mantém apenas o maior valor de cada janela 2×2, reduzindo a resolução espacial pela metade e preservando as respostas mais fortes do mapa de características.
Mapa de entrada (8×8) — janela atual destacada
Mapa reduzido (4×4)
Figura 9.6: Simulador interativo de pooling: escolha entre max-pooling e average-pooling e avance passo a passo para observar a redução da resolução espacial do mapa de características.

Em conjunto, convolução, função de ativação e pooling formam o bloco básico utilizado na construção de uma CNN.

9.4.6 Treinamento de Redes Neurais: Como as CNNs Aprendem

Uma CNN aprende ajustando automaticamente seus parâmetros — os coeficientes dos filtros convolucionais, os pesos das camadas totalmente conectadas e os vieses (biases) — a partir de exemplos rotulados. Esse treinamento é iterativo e envolve três etapas: medir o erro produzido pela rede por meio de uma função de perda (loss function), calcular como esse erro depende de cada parâmetro por meio da retropropagação (backpropagation) e atualizar os parâmetros com um algoritmo de otimização (optimizer).

9.4.6.1 Função de Perda (Loss Function)

A função de perda (loss function) quantifica a diferença entre a previsão da rede e a resposta correta, denominada verdade de referência (ground truth). O resultado é um escalar \(L\): quanto menor a perda, mais próxima a previsão está da resposta esperada.

Em problemas de classificação multiclasse, a função mais utilizada é a Entropia Cruzada (Cross-Entropy Loss), aplicada às probabilidades produzidas pela camada Softmax:

\[ L=-\sum_{c=1}^{C} y_c \log(\hat{y}_c), \]

em que \(C\) é o número de classes, \(y_c\) é o rótulo real em codificação one-hot e \(\hat{y}_c\) é a probabilidade prevista para a classe \(c\). A perda aproxima-se de zero quando a rede atribui alta probabilidade à classe correta e cresce rapidamente à medida que essa probabilidade diminui.

A Figura 9.7 ilustra esse comportamento: o simulador permite selecionar a classe correta e alterar as probabilidades produzidas pela Softmax, mostrando em tempo real a variação da função de perda.

📉 Simulador: Função de Perda (Entropia Cruzada) L = -log(ŷ_alvo)
CLASSE REAL DA IMAGEM (GROUND TRUTH: y_c = 1)
PROBABILIDADES ESTIMADAS PELA SOFTMAX (ŷ_c)
🏠 Casa (ŷ_1): 0.70
😊 Feliz (ŷ_2): 0.20
😢 Triste (ŷ_3): 0.10
Curva de Penalização L = -log(ŷ_alvo)
CÁLCULO DA PERDA:
Perda L = 0.3567
Figura 9.7: Simulador interativo da Função de Perda (Cross-Entropy): selecione a classe real da imagem (Casa, Feliz ou Triste) e ajuste as probabilidades estimadas pela Softmax para visualizar o cálculo da penalização escalar e o gráfico do logaritmo negativo em tempo real.

9.4.6.2 Retropropagação (Backpropagation)

Após o cálculo da perda, é necessário determinar como cada parâmetro da rede contribui para esse resultado. Essa etapa é realizada pela retropropagação (backpropagation), que aplica a Regra da Cadeia do cálculo diferencial para obter o gradiente da função de perda em relação a cada parâmetro.

Para um parâmetro \(w\), esse gradiente é dado por

\[ \frac{\partial L}{\partial w}. \]

O gradiente indica como a perda varia em relação a pequenas alterações em \(w\): um gradiente positivo indica que aumentar \(w\) aumenta a perda, e um gradiente negativo indica o efeito oposto.

A Figura 9.8 apresenta esse processo de forma visual, mostrando a propagação do gradiente da camada de saída até as primeiras camadas convolucionais.

⬅️ Simulador: Retropropagação (Backpropagation) ∂L/∂w = (∂L/∂y) · (∂y/∂z) · (∂z/∂w)
Passo 1 de 4: Saída (Loss & Softmax)
DIREÇÃO DA PROPAGAÇÃO DO ERRO (FLUXO REVERSO ⟵)
Conv1 Kernels
∂L/∂K
⟵
Max-Pooling
∂L/∂X_pool
⟵
Camadas FC
∂L/∂W_fc
⟵
Loss / Softmax
∂L/∂y_pred
🔗 Regra da Cadeia na Camada Atual:
Figura 9.8: Simulador interativo de Backpropagation: avance os passos da Regra da Cadeia para acompanhar o fluxo do sinal de erro no sentido inverso da rede, observando a computação das derivadas parciais do gradiente em cada camada.

9.4.6.3 Algoritmos de Otimização

Após o cálculo dos gradientes, um algoritmo de otimização (optimizer) atualiza os parâmetros da rede para reduzir a função de perda. Em redes profundas, essa busca ocorre em um espaço de alta dimensão e, em geral, não convexo, o que torna a otimização um problema desafiador.

Para facilitar a compreensão, a Figura 9.9 utiliza uma superfície de perda simplificada, com um mínimo global, um mínimo local e uma barreira entre essas regiões. O mínimo global corresponde ao menor valor da função de perda e representa o melhor conjunto de parâmetros da rede; um mínimo local também apresenta baixa perda, mas pode estar distante da melhor solução. Quando a otimização fica retida em um mínimo local, os ajustes de filtros, pesos e vieses tornam-se muito pequenos, e o treinamento para antes de alcançar um modelo com menor erro.

9.4.6.3.1 Gradiente Descendente Estocástico (SGD)

O Gradiente Descendente Estocástico (Stochastic Gradient Descent — SGD) atualiza os parâmetros na direção oposta ao gradiente:

\[ w_{\text{novo}} = w_{\text{atual}} - \eta \frac{\partial L}{\partial w}, \]

em que \(\eta\) é a taxa de aprendizado (learning rate), responsável por controlar o tamanho da atualização. O SGD utiliza apenas o gradiente da iteração atual; quando a busca alcança um mínimo local, os gradientes tornam-se muito pequenos e as atualizações praticamente cessam.

9.4.6.3.2 Otimizadores Adaptativos: Adam

O Adam (Adaptive Moment Estimation) combina estimativas adaptativas dos primeiros e segundos momentos dos gradientes (Kingma; Ba, 2015), adaptando a taxa de aprendizado de cada parâmetro individualmente. Essa adaptação favorece, em muitos casos, a superação de mínimos locais que reteriam o SGD.

A Figura 9.9 compara a trajetória do SGD e do Adam sobre a mesma superfície de perda não convexa.

⚡ Simulador: Otimização com Curvas de Nível (SGD vs. Adam) Relevo Não Convexo: Mínimo Local vs. Global
💡 Como ler este mapa: a seta amarela aponta na direção de descida (−∇L), que é o sentido oposto ao vetor gradiente (∇L). O otimizador avança nessa direção para reduzir a perda L(w1, w2) até atingir as regiões mais profundas (tons mais escuros).
ALGORITMO PRINCIPAL (linha sólida)
TAXA DE APRENDIZADO (η)
Mapa de Calor da Perda L(w₁, w₂) — clique para escolher o início
Mínimo Global Mínimo Local Gradiente (↓ descida) Trajetória principal Fantasma (outro otimizador)
ESTADO DA OTIMIZAÇÃO:
w₁ = 1.80, w₂ = 0.20
Loss L = 2.450
PASSO
0
|∇L| (MAGNITUDE)
0.000
Status: Ponto Inicial
Figura 9.9: Simulador interativo dos Algoritmos de Otimização: compare a trajetória do SGD e do Adam sobre uma superfície de perda não convexa com mapa de calor e curvas de nível. A linha sólida mostra o otimizador selecionado avançando passo a passo; a linha tracejada mostra, para comparação instantânea, o caminho completo que o outro otimizador percorreria a partir do mesmo ponto inicial. Observe como o SGD fica retido no Mínimo Local à direita, enquanto o Adam pode ou não transpor a barreira central dependendo do impulso acumulado e da taxa de aprendizado. Clique em qualquer ponto do mapa para redefinir o ponto inicial dos pesos.

9.4.7 Arquitetura de uma CNN

Uma CNN para classificação de imagens combina as camadas apresentadas nas seções anteriores. Durante o passo à frente (forward pass), a imagem percorre sucessivamente as camadas convolucionais, as funções de ativação, as operações de pooling, a etapa de Flatten, as camadas totalmente conectadas e, por fim, a camada Softmax, que produz as probabilidades das classes. Durante o treinamento, essa previsão é comparada ao rótulo correto para calcular a função de perda, realizar a retropropagação e atualizar os parâmetros por meio de um algoritmo de otimização.

A Figura 9.10 apresenta esse fluxo de processamento e treinamento.

Figura 9.10: Arquitetura simplificada de uma CNN para classificação de imagens, destacando o forward pass e as etapas de treinamento por meio da função de perda, da retropropagação e do algoritmo de otimização.

Após o último bloco convolucional, a operação Flatten reorganiza os mapas de características em um vetor unidimensional, que alimenta as camadas totalmente conectadas (fully connected layers), responsáveis por combinar as características extraídas para produzir os escores (logits) de cada classe. A camada Softmax converte esses escores em uma distribuição de probabilidades, utilizada tanto para a classificação quanto para o cálculo da função de perda durante o treinamento.

A Figura 9.11 apresenta uma versão interativa dessa arquitetura, permitindo executar sucessivas etapas de treinamento e observar a redução da perda, a retropropagação dos gradientes e a atualização dos filtros da rede.

⚙️ Simulador: Arquitetura Completa de uma CNN Entrada (12×12) → Conv → Pool → FC → Softmax
IMAGEM DE ENTRADA DO PIPELINE (12×12)
🎯 Treinamento (Forward Pass + Retropropagação)
Entrada ▸ Conv+ReLU ▸ Pool ▸ Flatten ▸ FC ▸ Softmax ▸ Previsão
Perda ◂ Otimizador ◂ Retropropagação ◂ (a cada passo)
PERDA (LOSS)
ACURÁCIA
Loss: —
Acurácia: —
Passo de treinamento: 0
Os kernels e pesos começam aleatórios (não são mais os filtros fixos do simulador anterior). A cada passo, a rede faz o forward pass nas 3 imagens, calcula a perda (cross-entropy) e a acurácia (quantas das 3 imagens são classificadas corretamente), retropropaga o erro e ajusta todos os pesos (inclusive os kernels da convolução) via gradiente descendente. ⚠️ Como o "conjunto de treino" tem apenas 3 exemplos, isso demonstra o mecanismo do treinamento (perda caindo, acurácia subindo, pesos mudando) — não a capacidade de generalizar para imagens novas, que exigiria muito mais dados.
Figura 9.11: Simulador interativo da arquitetura de uma CNN: escolha uma das imagens de entrada 12×12 (casa, rosto feliz ou triste), clique em cada bloco do pipeline — Entrada, Conv+ReLU, Pooling, Flatten, FC e Softmax — e execute passos de treinamento reais (forward pass + retropropagação) para observar a perda e a acurácia evoluindo, os kernels sendo ajustados e o Softmax passando a apontar a classe correta.

9.4.8 Como o Gradiente Ajusta os Kernels da Convolução

A compreensão do processo de aprendizado em uma Rede Neural Convolucional (CNN) requer a elucidação de um mecanismo fundamental: como os coeficientes aleatórios de um filtro inicial se transformam em detectores precisos de bordas, texturas e padrões complexos.

A resposta reside no princípio do compartilhamento de pesos (weight sharing). Durante a etapa de propagação à frente (forward pass), o mesmo filtro de dimensão \(3\times3\) desliza por toda a extensão da imagem de entrada. Consequentemente, cada peso do kernel — como o elemento \(K[0][0]\) no canto superior esquerdo — é reutilizado múltiplas vezes ao longo das diferentes regiões espaciais do dado de entrada.

Durante a etapa de retropropagação (backpropagation), essa reutilização estabelece uma dinâmica direta: cada posição espacial processada pelo filtro gera uma contribuição individual (“voto”) para a atualização do respectivo peso.

9.4.8.1 A Intuição por Trás do Cálculo

Seja \(Z[r][c]\) o mapa de características pré-ativação na posição \((r,c)\) da janela deslizante, obtido pela correlação cruzada entre o kernel \(K\) e a entrada \(X\):

\[ Z[r][c] = \sum_{k_r} \sum_{k_c} K[k_r][k_c] \cdot X[r + k_r][c + k_c] \]

Aplicando a regra da cadeia para determinar a contribuição de um peso específico \(K[k_r][k_c]\) na função de perda \(L\), obtêm-se as seguintes etapas:

  1. Erro Local (\(dZ\)): Em cada posição \((r,c)\), calcula-se a derivada parcial da função de perda em relação à pré-ativação: \[dZ[r][c] = \frac{\partial L}{\partial Z[r][c]}\] que quantifica a responsabilidade dessa posição específica no erro total da rede (\(L\)).

  2. Contribuição do Peso: Como \(\frac{\partial Z[r][c]}{\partial K[k_r][k_c]} = X[r + k_r][c + k_c]\), a influência de um peso específico \(K[k_r][k_c]\) no erro da posição \((r,c)\) é obtida multiplicando-se o erro local \(dZ[r][c]\) pelo valor do pixel de entrada alinhado a esse peso no instante do cálculo: \[dZ[r][c] \cdot X[r + k_r][c + k_c]\]

  3. Acúmulo de Gradientes: O gradiente final do peso corresponde à soma das contribuições (“votos”) de todas as posições percorridas pela janela deslizante:

\[ \frac{\partial L}{\partial K[k_r][k_c]} = \sum_{(r,c)} dZ[r][c] \cdot X[r + k_r][c + k_c] \]

Essa formulação assegura paridade direta entre a derivação analítica e os valores computados no simulador de inspeção do gradiente (Figura 9.12).

9.4.8.2 O Papel da Função ReLU como “Filtro de Relevância”

A aplicação da função de ativação ReLU (\(\max(0, z)\)) imediatamente após a convolução introduz uma propriedade de seletividade ao gradiente:

  • Ativação Positiva (\(Z[r][c] > 0\)): A derivada da ReLU é \(1\). O erro local é propagado integralmente (\(dZ \neq 0\)), permitindo que a posição contribua para a atualização dos pesos do kernel.
  • Ativação Inativa (\(Z[r][c] \le 0\)): A derivada da ReLU é \(0\). O erro local é anulado (\(dZ = 0\)), suprimindo a contribuição da posição para o gradiente final.

Nota didática: A ReLU assegura que apenas as regiões espaciais que produziram respostas ativas durante a propagação à frente possuam capacidade de modificar os pesos do kernel no processo de retropropagação.

9.4.8.3 Atualização dos Pesos via Gradiente Descendente

Após a consolidação dos gradientes acumulados de todas as posições, a atualização do peso ocorre segundo o algoritmo do Gradiente Descendente Estocástico (SGD):

\[ K[k_r][k_c] \leftarrow K[k_r][k_c] - \eta \cdot \frac{\partial L}{\partial K[k_r][k_c]} \]

em que \(\eta\) denota a taxa de aprendizado (learning rate).

  • Se a soma dos gradientes for positiva, o valor do peso é reduzido.
  • Se a soma for negativa, o valor do peso é incrementado.

9.4.8.4 Explorando o Simulador Interativo

Nota🔗 Da Arquitetura Global à Inspeção do Gradiente

No simulador de arquitetura (Figura 9.11), observa-se o erro \(dZ\) derivado da retropropagação multicamada completa, originado na perda de entropia cruzada (Softmax) sobre as imagens de entrada \(12\times12\).

Para viabilizar a verificação analítica do gradiente sem a sobrecarga de \(100\) posições de convolução e retropropagação multicamada, o simulador de aprendizado do kernel (Figura 9.12) adota um modelo de inspeção reduzido (\(6\times6\)). Nesse cenário, simplifica-se o problema substituindo a classificação complexa por uma metadado de calibração escalar: ajusta-se o filtro para produzir uma resposta acumulada predefinida (\(\text{alvo} = 9\)) ao identificar um padrão específico (como uma borda a 45 graus). O mecanismo de acúmulo de gradientes (\(dZ \cdot X\)) permanece rigorosamente idêntico em ambas as formulações.

Para inspecionar essa dinâmica em nível numérico, utiliza-se o simulador na Figura 9.12:

  • Imagem de Entrada (\(X\)): Matriz \(6\times6\).
  • Filtro Convolucional (\(K\)): Matriz \(3\times3\) (9 pesos).
  • Mapa de Saída (\(Z\) / \(A\)): Matriz \(4\times4\) (16 posições da janela).
  • Função de Perda (\(L\)): Definida por \(L = \frac{1}{2}(S - \text{alvo})^2\), em que \(S = \sum A[r][c]\) representa a soma global das ativações pós-ReLU.

O papel do \(\text{alvo} = 9\): O valor escalar \(\text{alvo} = 9\) representa a “energia de ativação” ideal estipulada para a imagem com borda diagonal. Como o mapa \(A\) possui 16 posições, esse valor equivale a buscar uma resposta média de \(\frac{9}{16} \approx 0,56\) por pixel ativado. Quando \(S > 9\), a rede identifica que o filtro está reagindo com intensidade excessiva ao padrão, gerando um erro \(dZ > 0\) que força a redução dos pesos \(K\). Quando \(S < 9\), os pesos são incrementados para amplificar o sinal.

9.4.8.4.1 Roteiro Sugerido de Experimentação:
  1. Seleção de Peso: Na grade \(3\times3\), escolha o peso a ser analisado (ex.: \(K[0][0]\)).
  2. Varredura da Janela: Utilize o botão “▶ Avançar posição” para acompanhar o deslocamento da janela pelas 16 posições espaciais. Note o destaque visual na célula do mapa de entrada que alinha o pixel \(X\) ao peso selecionado.
  3. Análise do Voto Local: Examine o produto do erro local pelo pixel de entrada (\(dZ \cdot X\)) no painel de cálculo da posição.
  4. Verificação do Histórico: Acompanhe a consolidação dos 16 resultados parciais organizados nas quatro colunas de histórico, observando o acúmulo do gradiente final.
  5. Atualização do Kernel: Clique em “▶ Aplicar passo de gradiente descendente” para visualizar a convergência da curva de perda e a adaptação do kernel aleatório ao padrão de entrada selecionado.
🧮 Simulador: Gradiente de um Peso do Kernel ∂Perda / ∂K[kr][kc] = Σ dZ · X
PADRÃO DE ENTRADA (IMAGEM 6×6)? Escolhe qual imagem 6×6 alimenta a convolução.
TAXA DE APRENDIZADO (η)? Taxa de aprendizado. Ajuste para ver a diferença entre convergência suave (0.002) e colapso por overshooting (0.02).
Exemplo reduzido: imagem 6×6 e filtro 3×3 gerando mapas 4×4. Clique nas abas "🔍 Como é calculado?" abaixo de cada matriz para entender as contas passo a passo. Passe o mouse sobre qualquer célula de X, Z, A, dZ ou K para ver a conta exata daquele valor, com os elementos usados nas camadas relacionadas destacados com contorno tracejado/azul.
1. PESO DO KERNEL? Selecione qual peso do kernel você deseja analisar individualmente.
KERNEL ATUAL (K)? Valores do filtro 3×3. O peso selecionado é destacado em azul. Passe o mouse sobre um peso para ver onde ele é usado.
🔍 Como é atualizado?
Regra do Gradiente:
K ← K − η · ∇K
• η = taxa de aprendizado.
• ∇K = soma dos 16 votos dZ × X.
ENTRADA X (6×6)? Imagem 6×6. Pixel azul = sobreposição com o peso K selecionado na janela atual. Passe o mouse sobre um pixel para ver em quais posições de Z ele é usado.
🔍 Como funciona X?
Matriz de entrada. Na posição (r,c), o peso K multiplica o pixel:
X[r + kr][c + kc]
PRÉ-ATIVAÇÃO Z (4×4)? Resultado da convolução antes do ReLU: Z = Σ K · X. Passe o mouse sobre uma célula para ver os 9 termos da soma, destacando a janela em X e todo o kernel K.
🔍 Como calcula Z?
Correlação cruzada:
Multiplicação ponto a ponto do filtro 3×3 sobre X:
Z[r][c] = Σ K · X
ATIVAÇÃO A (4×4)? Resultado pós-ReLU: A = max(0, Z). Se Z ≤ 0, a ativação é zerada. Passe o mouse sobre uma célula para destacar o Z correspondente.
🔍 Como calcula A?
Função ReLU:
A[r][c] = max(0, Z[r][c])
Soma Global (S):
S = Σ A[r][c]
ERRO dZ (4×4)? Erro propagado: dZ = (S - alvo) · I(Z > 0). Onde A=0, o erro dZ também é 0. Passe o mouse sobre uma célula para ver a conta completa, destacando o Z correspondente e todas as 16 células de A que formam S.
🔍 Como calcula dZ, S e Loss?
1. Perda (Loss L):
L = ½ (S − alvo)²
2. Erro propagado dZ:
dZ = (S − alvo) · deriv_ReLU(Z)
2. CÁLCULO E SOMA DOS "VOTOS" DE CADA POSIÇÃO? Cada posição (r,c) gera um voto = dZ[r][c] × X[r+kr][c+kc]. A soma de todos os 16 votos forma o gradiente do peso.
CÁLCULO DESTA POSIÇÃO? Exibe o erro local (dZ) e o pixel de entrada (X) multiplicados na posição atual da janela deslizante.
SOMA ACUMULADA (GRADIENTE)? O valor acumulado dos produtos dZ × X de todas as posições já percorridas. Quando atinge 16/16, este é o gradiente final do peso.
HISTÓRICO DAS 16 POSIÇÕES (COLUNAS c=0, c=1, c=2, c=3)? Acompanhe a lista de todas as 16 posições organizadas em 4 colunas para corresponder ao movimento da janela sobre a imagem de saída.
3. USE O GRADIENTE PARA ATUALIZAR O KERNEL? Aplica a regra do Gradiente Descendente (K ← K − η · gradiente) para todos os 9 pesos.
PERDA AO LONGO DAS ATUALIZAÇÕES? Evolução do erro L = ½(S − alvo)²:
• Objetivo: L → 0 (S → alvo).
• Se travar em L = 40.5: Ocorreu "overshooting" (salto exagerado). Os pesos ficaram muito negativos, gerando Z ≤ 0 (morte da ReLU). Com S = 0, a perda trava em ½(0 − 9)² = 40.5.
Figura 9.12: Simulador interativo do cálculo do gradiente de um peso do kernel convolucional.
Nota🧠 Síntese — Da convolução ao aprendizado de representações

Os simuladores desta seção demonstram, de forma sequencial, como uma CNN transforma uma imagem de entrada em uma estimativa probabilística e como seus parâmetros são otimizados durante o treinamento:

  • Convolução: aplica filtros sobre a imagem para extrair características locais, gerando mapas de características por meio do compartilhamento de pesos.
  • ReLU: introduz não linearidade ao sistema, permitindo a modelagem de relações complexas entre os dados.
  • Pooling: reduz a resolução espacial dos mapas de características, diminuindo o custo computacional e conferindo invariância a pequenas translações locais.
  • Flatten: reorganiza os mapas multidimensionais em um vetor unidimensional para alimentação das camadas subsequentes.
  • Camada totalmente conectada: combina as características extraídas para produzir os escores brutos (logits) associados a cada classe.
  • Softmax: converte os logits em uma distribuição de probabilidades normalizada.
  • Função de perda: compara a distribuição prevista com a verdade de referência (ground truth), quantificando escalarmente o erro da rede.
  • Retropropagação: aplica a regra da cadeia para calcular a derivada parcial (gradiente) da função de perda em relação a cada parâmetro treinável.
  • Otimizador: atualiza os coeficientes dos filtros, pesos e vieses na direção oposta ao gradiente, reduzindo a perda a cada iteração.

Ao longo das iterações, os filtros convolucionais convertem-se de valores estocásticos em detectores especializados: as camadas iniciais aprendem primitivas visuais de baixo nível (como bordas e texturas), enquanto as camadas mais profundas consolidam essas representações em estruturas abstratas e semânticas.

9.5 Aplicações Práticas em VC

Após a consolidação teórica dos fundamentos das CNNs e a verificação visual de cada uma de suas operações elementares por meio dos simuladores interativos, torna-se essencial observar a integração dessas etapas em pipelines completos de programação.

Nas seções a seguir, a teoria é traduzida em código executável em PyTorch, explorando as três tarefas fundamentais da VC: classificação, detecção de objetos e segmentação semântica. Essa progressão prática permite analisar desde a construção de uma arquitetura convolucional treinada do zero até a aplicação de estratégias avançadas de transferência de aprendizado (transfer learning) em modelos pré-treinados para conjuntos de dados sintéticos e reais.

9.5.1 Classificação de Imagens com CNNs

A classificação de imagens é uma das aplicações mais tradicionais das CNNs. Nessa tarefa, o objetivo é atribuir um único rótulo à imagem de entrada, como identificar uma categoria de objeto, uma espécie de animal ou uma classe de diagnóstico. Para isso, a CNN transforma progressivamente os valores dos pixels em representações de maior nível de abstração, combinando camadas convolucionais, funções de ativação e operações de redução espacial até produzir uma distribuição de probabilidades entre as classes possíveis. Nesta seção, são apresentados a arquitetura básica de uma CNN classificadora, o fluxo de transformação dos dados ao longo da rede e o processo de treinamento para ajuste dos parâmetros aprendidos.

9.5.1.1 Treinamento de uma CNN do Zero em Dígitos

Para estabelecer uma comparação direta com as abordagens apresentadas no Capítulo 7, desenvolve-se nesta seção uma CNN treinada sobre o mesmo conjunto de dados de dígitos manuscritos (load_digits). A diferença fundamental reside na etapa de representação: enquanto os métodos clássicos dependem de pixels brutos ou de descritores calculados manualmente, como o Histogram of Oriented Gradients (HOG), a CNN aprende automaticamente os coeficientes dos filtros convolucionais durante o processo de otimização.

Os códigos a seguir (consolidados na Figura 9.15) realizam a preparação dos dados, definem uma arquitetura convolucional simples em PyTorch, executam o laço de treinamento por meio do algoritmo Adam e geram as curvas de evolução da função de perda e da acurácia.

9.5.1.1.1 Bloco 1: Preparação e Estruturação dos Dados

A etapa inicial de qualquer pipeline de Aprendizado Profundo consiste na conversão e adequação dos dados de entrada para o formato exigido pelo framework de computação científica.

9.5.1.1.1.1 O Conceito de Tensor

Em Aprendizado Profundo, a estrutura fundamental de dados é o tensor. Do ponto de vista computacional, um tensor consiste em um arranjo multidimensional de números generalizado para \(n\) dimensões:

  • Um tensor de ordem 0 é um escalar (um único valor).
  • Um tensor de ordem 1 é um vetor (comprimento).
  • Um tensor de ordem 2 é uma matriz (linhas e colunas).
  • Um tensor de ordem 3 ou superior representa um volume ou hiper-arranjo de dados.

No contexto do PyTorch, a classe torch.Tensor estende a funcionalidade de arranjos numéricos multidimensionais (como os do NumPy) ao oferecer suporte a operações aceleradas em hardware por meio de GPUs (Graphics Processing Units) e suporte ao cálculo automático de derivadas (autograd), essencial para o algoritmo de retropropagação.

9.5.1.1.1.2 Análise do Código de Pré-processamento
  1. Carregamento e Normalização das Intensidades: O conjunto load_digits possui \(1.797\) amostras de dígitos manuscritos de \(8 \times 8\) pixels, cujas intensidades originais variam na escala inteira de \(0\) a \(16\). A divisão por \(16.0\) realiza a normalização dos dados para a faixa \([0.0, 1.0]\). Essa transformação em escala flutuante (float32) é indispensável em redes neurais para evitar a saturação das funções de ativação e estabilizar o cálculo dos gradientes no algoritmo de otimização.

  2. Divisão Estratificada (70% Treinamento / 30% Teste): A função train_test_split separa \(70\%\) das amostras para o ajuste dos parâmetros da rede e reserva \(30\%\) para a avaliação do modelo em dados não vistos. O parâmetro stratify=y garante a amostragem estratificada, mantendo a proporção exata de cada uma das 10 classes de dígitos (\(0\) a \(9\)) em ambos os conjuntos, prevenindo viés de distribuição.

  3. Adequação Dimensional para Convolução 2D (unsqueeze): Nas CNNs, camadas convolucionais bidimensionais (nn.Conv2d) exigem que o tensor de entrada possua estritamente 4 dimensões na ordenação \((N, C, H, W)\):

    • \(N\): número de amostras (batch size).
    • \(C\): número de canais de cor (\(1\) para escala de cinza, \(3\) para RGB).
    • \(H\): altura da imagem em pixels (\(8\)).
    • \(W\): largura da imagem em pixels (\(8\)).

    Como o arranjo original possui formato \(3\text{D}\) do tipo \((N, 8, 8)\), a chamada .unsqueeze(1) insere uma dimensão unitária especificamente no índice 1 (a posição reservada para o canal de cor \(C\)), transformando a estrutura em um tensor \(4\text{D}\) de formato \((N, 1, 8, 8)\), conforme exigido pelo PyTorch.

  4. Conversão dos Rótulos (dtype=torch.long): Os rótulos das classes \(y\) são convertidos em tensors inteiros de 64 bits (torch.long). Essa especificação de tipo é uma exigência da função de perda de Entropia Cruzada (nn.CrossEntropyLoss), que utiliza inteiros não negativos como índices para associar a classe correta aos logits de saída da rede.

Atenção às Dimensões: A estrutura final é representada pelo tensor (N, 1, 8, 8), onde N é o número de amostras (batch size), 1 é o canal de cor (escala de cinza) e 8×8 é a resolução espacial da imagem em pixels.

A Figura 9.13 ilustra uma sequência de amostras do conjunto de treinamento após o pré-processamento e adequação dimensional aos tensors do PyTorch. Na etapa de exibição, a chamada img.squeeze().numpy() encadeia duas transformações: o método .squeeze() elimina a dimensão unitária redundante do canal de cor, reduzindo o tensor \(3\text{D}\) de formato (1, 8, 8) para uma matriz \(2\text{D}\) de (8, 8); em seguida, o método .numpy() converte a estrutura do PyTorch em uma matriz nativa do NumPy, formato exigido pelas ferramentas de renderização gráfica como a mm.show().

# 1. Carregamento e pré-processamento dos dados
digits = load_digits()
X = digits.images.astype(np.float32) / 16.0  # Normalização para a faixa [0, 1]
y = digits.target

# Divisão estratificada em conjuntos de treinamento (70%) e teste (30%)
X_treino, X_teste, y_treino, y_teste = train_test_split(
    X, y, test_size=0.3, random_state=42, stratify=y
)

# Adequação à dimensão esperada pelo PyTorch: (N_amostras, Canais, Altura, Largura)
X_treino_t = torch.tensor(X_treino).unsqueeze(1)   # Dimensão: (N, 1, 8, 8)
y_treino_t = torch.tensor(y_treino, dtype=torch.long)
X_teste_t = torch.tensor(X_teste).unsqueeze(1)
y_teste_t = torch.tensor(y_teste, dtype=torch.long)

# Exibir uma amostra
n_amostras = 8
imgs = [img.squeeze().numpy() for img in X_treino_t[:n_amostras]]
imgs_titles = [str(label.item()) for label in y_treino_t[:n_amostras]]
mm.show(imgs, titles=imgs_titles, cols=n_amostras, figsize=(12, 2.5))
Figura 9.13: Amostras de dígitos do conjunto de treinamento após conversão para tensores PyTorch e normalização.
9.5.1.1.2 Bloco 2: Definição da Arquitetura Convolucional

A construção de modelos no PyTorch é estruturada a partir do paradigma de orientação a objetos, criando-se uma classe específica para representar a rede neural (neste exemplo, a classe CNNDigitos), a qual herda todas as funcionalidades da classe base nn.Module. O construtor __init__ é responsável por instanciar as camadas e declarar seus parâmetros treináveis, enquanto o método forward estabelece a sequência numérica da propagação à frente (forward pass).

A Figura 9.14 sintetiza as transformações espaciais dos tensors e o fluxo de dados ao longo da classe CNNDigitos.

Figura 9.14: Representação do fluxo de transformações dimensionais dos tensors ao longo da arquitetura CNNDigitos.
  1. Construtor (__init__) e Instanciação dos Componentes:
    • Camada Convolucional 1 (self.conv1): Aplica \(8\) filtros \(3 \times 3\) com padding=1 sobre a entrada em escala de cinza (\(1\) canal), preservando a resolução espacial de \(8 \times 8\) pixels.
    • Camada Convolucional 2 (self.conv2): Processa os \(8\) mapas de características recebidos da camada anterior aplicando \(16\) filtros \(3 \times 3\) com padding=1.
    • Subamostragem (self.pool): Instancia a operação de Max-Pooling com janela \(2 \times 2\) e passo (stride) \(2\), reduzindo a dimensão espacial (altura e largura) pela metade a cada aplicação.
    • Camadas Totalmente Conectadas (self.fc1 e self.fc2): A primeira projeção densa recebe o tensor achatado de dimensão \(16 \times 2 \times 2 = 64\) e produz \(32\) características intermediárias. A segunda projeta essas \(32\) características nos \(10\) logits finais de saída.
  2. Propagação à Frente no Método forward:
    • Primeiro Bloco Convolucional: O tensor de entrada de formato \((N, 1, 8, 8)\) passa por conv1 + ReLU e é subamostrado por pool, resultando no formato \((N, 8, 4, 4)\).
    • Segundo Bloco Convolucional: O tensor \((N, 8, 4, 4)\) é processado por conv2 + ReLU e reduzido por pool para o formato \((N, 16, 2, 2)\).
    • Achatamento (Flatten): O método x.view(x.size(0), -1) reconfigura a estrutura \(3\text{D}\) em um vetor \(1\text{D}\) de \(64\) elementos por amostra, preservando a dimensão do lote \(N\).
    • Classificação: O vetor de \(64\) elementos alimenta fc1 com ativação ReLU (\(32\) neurônios) e finaliza em fc2, produzindo os \(10\) logits não normalizados para o cálculo da função de perda.
# 2. Definição da Arquitetura Convolucional
class CNNDigitos(nn.Module):
    """
    Arquitetura convolucional compacta:
    2 camadas convolucionais com ReLU e Max-Pooling + 2 camadas densas.
    """
    def __init__(self, n_classes=10):
        super().__init__()
        
        # Conv1: 1 canal de entrada, 8 filtros 3x3 com padding 1 (saída: 8x8)
        self.conv1 = nn.Conv2d(1, 8, kernel_size=3, padding=1)
        # Conv2: 8 canais de entrada, 16 filtros 3x3 com padding 1 (saída: 4x4)
        self.conv2 = nn.Conv2d(8, 16, kernel_size=3, padding=1)

        self.relu = nn.ReLU()

        # Max-Pooling 2x2 com passo (stride) 2
        self.pool = nn.MaxPool2d(2, 2)

        # Camadas totalmente conectadas (FC)
        self.fc1 = nn.Linear(16 * 2 * 2, 32)
        self.fc2 = nn.Linear(32, n_classes)

    def forward(self, x):
        # Primeiro bloco: Conv (8x8) -> ReLU -> Pool (4x4)
        x = self.pool(self.relu(self.conv1(x)))

        # Segundo bloco: Conv (4x4) -> ReLU -> Pool (2x2)
        x = self.pool(self.relu(self.conv2(x)))
        
        # Achatamento (Flatten): reconfigura a matriz 3D (16, 2, 2) em vetor 1D (64)
        x = x.view(x.size(0), -1)

        # Camada densa intermediária com ReLU
        x = self.relu(self.fc1(x))

        # Camada final de classificação (logits)
        return self.fc2(x)
9.5.1.1.2.1 Análise das Camadas e do Fluxo da Classe CNNDigitos
  1. Construtor (__init__) e Instanciação dos Componentes:
    • Camada Convolucional 1 (self.conv1): Aplica \(8\) filtros \(3 \times 3\) com padding=1 sobre a entrada em escala de cinza (\(1\) canal), preservando a resolução de \(8 \times 8\) pixels.
    • Camada Convolucional 2 (self.conv2): Processa os \(8\) mapas de características recebidos aplicando \(16\) filtros \(3 \times 3\) com padding=1.
    • Subamostragem (self.pool): Instancia a operação de Max-Pooling com janela \(2 \times 2\) e passo (stride) \(2\), reduzindo as dimensões espaciais (altura e largura) pela metade a cada aplicação.
    • Camadas Totalmente Conectadas (self.fc1 e self.fc2): A primeira projeção densa recebe o tensor achatado de dimensão \(16 \times 2 \times 2 = 64\) e produz \(32\) características intermediárias. A segunda projeta essas \(32\) características nos \(10\) logits de saída.
  2. Propagação à Frente no Método forward:
    • Primeiro Bloco: O tensor \((N, 1, 8, 8)\) passa por conv1 + ReLU e é reduzido por pool para \((N, 8, 4, 4)\).
    • Segundo Bloco: O tensor \((N, 8, 4, 4)\) passa por conv2 + ReLU e é reduzido por pool para \((N, 16, 2, 2)\).
    • Achatamento (Flatten): O método x.view(x.size(0), -1) converte a estrutura \(3\text{D}\) em um vetor \(1\text{D}\) de \(64\) elementos por amostra.
    • Classificação: O vetor de \(64\) elementos alimenta fc1 com ativação ReLU (\(32\) neurônios) e finaliza em fc2, que produz os \(10\) logits finais para o cálculo da perda de Entropia Cruzada.
9.5.1.1.3 Bloco 3: Instanciação e Parâmetros de Otimização

A etapa de configuração do aprendizado exige a instanciação da arquitetura definida e a escolha de dois componentes centrais: a função de perda, que quantifica o erro do modelo, e o algoritmo de otimização, responsável por ajustar os parâmetros em direção ao mínimo dessa função.

  1. Instanciação e Contagem de Parâmetros: O modelo é criado a partir da instanciação do objeto modelo_cnn da classe CNNDigitos. A expressão sum(p.numel() for p in modelo_cnn.parameters()) percorre todos os tensors de parâmetros treináveis da rede (pesos e vieses de cada camada) e calcula a cardinalidade total do modelo, quantificando sua capacidade de representação.

  2. Função de Perda (nn.CrossEntropyLoss): A perda de Entropia Cruzada (Cross-Entropy Loss) é a escolha padrão para problemas de classificação multiclasse. No PyTorch, essa implementação combina internamente a aplicação da função LogSoftmax com a Perda de Log-Verossimilhança Negativa (NLLLoss). Por essa razão, a camada de saída da rede produz logits brutos, dispensando a aplicação explícita da função Softmax ao final do método forward.

  3. Otimizador Adaptativo (optim.Adam): A atualização dos parâmetros utiliza o algoritmo Adam (Adaptive Moment Estimation), com taxa de aprendizado inicial \(\eta = 0,01\) (lr=1e-2). O Adam combina os princípios do momento com a adaptação do tamanho do passo baseada na média móvel das derivadas de primeira e segunda ordens, ajustando individualmente a taxa de aprendizado de cada parâmetro da rede.

# 3. Inicialização do Modelo e Parâmetros de Otimização
modelo_cnn = CNNDigitos()
num_params = sum(p.numel() for p in modelo_cnn.parameters())
print(f"Parâmetros treináveis do modelo: {num_params}")

criterio = nn.CrossEntropyLoss()
otimizador = optim.Adam(modelo_cnn.parameters(), lr=1e-2)
Parâmetros treináveis do modelo: 3658
9.5.1.1.4 Bloco 4: Laço de Treinamento e Avaliação

O treinamento de uma CNN ocorre de forma iterativa por meio do algoritmo de Gradiente Descendente Estocástico por mini-batches (Mini-batch SGD).

As curvas de aprendizado resultantes desse processo são apresentadas na Figura 9.15, gerada ao final da execução.

  1. Fase de Treinamento (modelo_cnn.train()): O laço principal executa o treinamento ao longo de \(50\) épocas. Em cada época, ocorrem as seguintes etapas:

    • Embaralhamento Estocástico: A função torch.randperm(n) gera uma permutação aleatória dos índices das amostras, garantindo que a ordenação dos mini-batches varie a cada época para evitar vícios de amostragem.
    • Divisão em Mini-batches: O conjunto de treinamento é fatiado em lotes de \(32\) amostras (tam_lote = 32).
    • Zerar Gradientes (otimizador.zero_grad()): Limpa os gradientes acumulados no tensor na iteração anterior, evitando a soma indesejada de derivadas entre lotes distintos.
    • Passo à Frente e Perda: O forward pass calcula as previsões saida, e a chamada criterio(saida, y_treino_t[idx]) quantifica o erro do lote.
    • Retropropagação (perda.backward()): Aplica a regra da cadeia para calcular as derivadas parciais da perda em relação a cada parâmetro (\(\frac{\partial L}{\partial w}\)).
    • Atualização dos Pesos (otimizador.step()): Atualiza os parâmetros do modelo segundo as equações do otimizador Adam.
  2. Fase de Avaliação (modelo_cnn.eval()): Ao final de cada época, o modelo é alterado para o modo de avaliação. O contexto with torch.no_grad() desativa temporariamente o motor de cálculo automático de derivadas (autograd), reduzindo o consumo de memória e acelerando a inferência sobre o conjunto de teste (X_teste_t). A operação .argmax(dim=1) extrai a classe de maior probabilidade para cada amostra, permitindo calcular a acurácia de teste.

  3. Visualização com a Biblioteca morph: A função mm.showTrainCurves da biblioteca didática morph consolida o histórico de perda de treinamento e a acurácia de teste em um único painel gráfico, permitindo diagnosticar a convergência do modelo e monitorar a estabilidade do aprendizado ao longo das épocas.

# 4. Laço de Treinamento (Mini-batch SGD)
n = X_treino_t.size(0)                    # Número de amostras
tam_lote = 32                             # Tamanho do mini-batch
epocas = 50                               # Total de épocas
historico_perda, historico_acc = [], []   # Histórico de métricas

for epoca in range(epocas):                  # Repete por época
    modelo_cnn.train()                       # Modo treinamento
    perm = torch.randperm(n)                 # Embaralha amostras
    perda_epoca = 0.0                        # Acumula perdas
    for i in range(0, n, tam_lote):          # Percorre mini-batches
        idx = perm[i:i + tam_lote]           # Índices do lote
        otimizador.zero_grad()               # Zera gradientes
        saida = modelo_cnn(X_treino_t[idx])  # Propagação direta
        perda = criterio(saida, y_treino_t[idx]) # Calcula perda
        perda.backward()                         # Retropropagação
        otimizador.step()                        # Atualiza pesos
        perda_epoca += perda.item() * len(idx)   # Soma perda

    # Avaliação do modelo no conjunto de teste ao final de cada época
    modelo_cnn.eval()                            # Modo avaliação
    with torch.no_grad():                        # Sem gradientes
        pred_teste = modelo_cnn(X_teste_t).argmax(dim=1)  # Predições
        acc_teste = (pred_teste == y_teste_t).float().mean().item()  # Acurácia
    historico_perda.append(perda_epoca / n)      # Registra perda
    historico_acc.append(acc_teste)              # Registra acurácia

acc_final_cnn = historico_acc[-1]                # Última acurácia
print(f"Acurácia final da CNN no conjunto de teste: {acc_final_cnn:.4f}")  # Exibe resultado

final = mm.showTrainCurves(                      # Plota curvas
    historico_perda, historico_acc,
    titulo="Evolução do Treinamento da CNN — Base de Dígitos",
    subtitulo=f"Acurácia final no teste: {acc_final_cnn:.4f}",
)
Acurácia final da CNN no conjunto de teste: 0.9759
Figura 9.15: Curvas de treinamento e avaliação da CNN na base de dígitos: evolução da perda de entropia cruzada no conjunto de treinamento e da acurácia no conjunto de teste ao longo de 50 épocas.
9.5.1.1.5 Bloco 5: Visualização do Fluxo de Ativações

A inspeção da rede treinada permite observar a transformação progressiva do tensor de entrada ao longo das camadas da arquitetura CNNDigitos. A Figura 9.16 ilustra as dimensões e as ativações intermediárias obtidas ao processar um exemplo real do dígito \(3\).

  1. Seleção e Preparação da Amostra: A semente estocástica é fixada com torch.manual_seed(7) para assegurar a reprodutibilidade dos resultados. A primeira ocorrência do dígito \(3\) no conjunto de dados load_digits é isolada, normalizada para o intervalo \([0.0, 1.0]\) e reconfigurada como um tensor x de dimensão \((1, 1, 8, 8)\).

  2. Inspeção Intermediária com mm.showNet: A função mm.showNet da biblioteca morph executa a propagação à frente (forward pass) do tensor x na instância modelo_cnn previamente treinada. Utilizando hooks de forward, a função intercepta o estado numérico das ativações nas camadas convolucionais (nn.Conv2d), de agrupamento (nn.MaxPool2d) e totalmente conectadas (nn.Linear), retornando-as no dicionário acts. As funções de ativação não linear (nn.ReLU) não são registradas como estágios independentes, pois sua aplicação ocorre diretamente sobre o tensor de saída da camada correspondente.

  3. Verificação dos Resultados: A instrução list(acts.keys()) exibe a sequência de identificadores das camadas monitoradas, permitindo confirmar a redução dimensional progressiva e a geração do logit de valor máximo no índice correspondente à classe \(3\), conforme demonstrado na Figura 9.16.

torch.manual_seed(7)
digits = load_digits()
idx = np.where(digits.target == 3)[0][0]
img = digits.images[idx] / 16.0
x = torch.tensor(img, dtype=torch.float32).view(1, 1, 8, 8)

# Reutilização da instância do modelo previamente treinada
acts = mm.showNet(
    modelo_cnn,
    x,
    titulo="Fluxo de transformações dos tensors ao longo da arquitetura CNNDigitos",
    subtitulo=f"Exemplo real do dataset load_digits (classe verdadeira: {digits.target[idx]})",
)
print("Camadas capturadas:", list(acts.keys()))
Figura 9.16: Fluxo de ativações da CNN treinada ao processar um exemplo real do dígito 3, do dataset load_digits: dimensões dos tensores camada a camada, da entrada ao logit de saída.
Camadas capturadas: ['conv1', 'pool', 'conv2', 'pool #2', 'fc1', 'fc2']
9.5.1.1.6 Inspecionando o Grafo Computacional com torchviz

Enquanto mm.showNet prioriza a clareza didática — exibindo uma coluna por camada com parâmetros treináveis —, a biblioteca torchviz projeta o grafo de autograd exatamente como o PyTorch o constrói internamente para o cálculo de gradientes. A Figura 9.17 ilustra essa perspectiva ao representar a arquitetura CNNDigitos.

  1. Propagação à Frente Rastreada: Com o modelo treinado em modo eval(), o forward pass sobre o tensor x do dígito \(3\) é suficiente para que o motor de autograd registre todas as operações executadas, incluindo aquelas sem parâmetros treináveis, como a função de ativação ReLU e a reconfiguração dimensional view.

  2. Geração do Grafo (make_dot): A função make_dot(saida, params=...) constrói o grafo a partir do tensor de saída, percorrendo retroativamente o histórico de operações até os nós folha (os parâmetros treináveis do modelo). Cada nó do diagrama representa uma operação do backward pass (como ReluBackward ou AddmmBackward), e não apenas um bloco conceitual do nn.Module.

  3. Exportação e Renderização (.render): O método .render(..., format="png", cleanup=True) invoca o executável dot do Graphviz para compilar a imagem em formato PNG, eliminando automaticamente os arquivos intermediários de código-fonte.

A Figura 9.17 evidencia como esse grafo computacional, mesmo para uma arquitetura compacta, apresenta maior densidade que o painel do mm.showNet, pois detalha cada operação atômica responsável pelo fluxo de gradientes.

# 1. Forward pass com rastreamento de gradiente habilitado
modelo_cnn.eval()
saida = modelo_cnn(x)  # Reaproveitamento do tensor x (dígito 3)

# 2. Grafo básico: fluxo de operações até a saída
grafo_simples = make_dot(saida, params=dict(modelo_cnn.named_parameters()))
caminho_simples = grafo_simples.render("cnn_digitos_grafo_simples", format="png", cleanup=True)

# 3. Exibição direta no ambiente Quarto/Jupyter
# O .render() retorna o caminho do arquivo PNG gerado; precisamos abri-lo como imagem
imagem_simples = np.array(Image.open(caminho_simples).convert("RGB"))
mm.show(imagem_simples, figsize=(5,10))
Figura 9.17: Grafo computacional da CNNDigitos gerado via torchviz, mostrando as operações de forward e os nós de gradiente (backward) associados a cada parâmetro treinável.
9.5.1.1.7 Visão Detalhada do Grafo Computacional com torchviz

Além da representação simplificada, a biblioteca torchviz permite expandir o grafo de autograd para inspecionar os detalhes internos de execução da rede CNNDigitos. A Figura 9.18 apresenta essa estrutura expandida para o mesmo tensor de entrada x.

  1. Rastreamento com Atributos de Operação (show_attrs=True): A inclusão de atributos exibe as configurações hiperparamétricas associadas a cada nó computacional durante a propagação à frente (forward pass), tais como dimensões de kernel (kernel_size), passos (stride) e preenchimentos (padding) nas convoluções e subamostragens.

  2. Detecção de Tensors Salvos em Memória (show_saved=True): O parâmetro força a exibição explícita dos tensors intermediários que o PyTorch retém na memória durante o forward pass. Esses dados são preservados porque serão estritamente necessários para o cálculo das derivadas parciais durante a etapa de retropropagação (backward pass).

  3. Geração e Compilação dos Grafos: Enquanto grafo_simples gera uma visão direta do fluxo de gradientes, grafo_detalhado compila o grafo expandido no arquivo cnn_digitos_grafo_detalhado.png via executável dot do Graphviz.

Conforme observado na Figura 9.18, essa visualização minuciosa é útil para depurar o consumo de memória de vídeo (VRAM) e verificar como o motor do PyTorch aloca internamente cada nó da regra da cadeia.

# mesmo código anterior (make_dot + render)...

# 2. Grafo detalhado: exibição de dimensões e tensores salvos para o backward
grafo_detalhado = make_dot(
    saida,
    params=dict(modelo_cnn.named_parameters()),
    show_attrs=True,   # Exibe atributos das operações (ex.: kernel_size, stride)
    show_saved=True,   # Exibe tensores salvos na memória para a retropropagação
)

caminho_detalhado = grafo_detalhado.render("cnn_digitos_grafo_detalhado", 
                                           format="png", cleanup=True)

# 3. Exibição direta no ambiente Quarto/Jupyter
imagem_detalhado = np.array(Image.open(caminho_detalhado).convert("RGB"))
mm.show(imagem_detalhado, figsize=(8, 16))
Figura 9.18: Grafo detalhado da classe CNNDigitos gerado via torchviz.
9.5.1.1.8 Comparando com o Capítulo 7

A Figura 9.19 reúne os resultados obtidos no mesmo conjunto de dados (load_digits), estabelecendo um paralelo direto entre as abordagens clássicas exploradas anteriormente e a CNN desenvolvida neste capítulo.

  1. Desempenho dos Pixels Brutos vs. Descritores Manuais: Nos experimentos do Capítulo 7, o classificador \(k\text{-NN}\) (\(k=3\)) atingiu uma acurácia de \(98,4\%\) quando alimentado diretamente com os pixels brutos das imagens. Em contrapartida, a extração prévia de características via Histogram of Oriented Gradients (HOG) resultou em um desempenho significativamente inferior (\(75,8\%\)). Essa queda ocorre porque o HOG foi concebido para capturar gradientes de bordas em imagens de maior resolução; em matrizes de apenas \(8 \times 8\) pixels, a resolução espacial é insuficiente para formar histogramas de orientação informativos.

  2. Equivalência da CNN e Aprendizado End-to-End: A rede convolucional CNNDigitos alcança um desempenho competitivo de \(97,6\%\), aproximando-se da acurácia do \(k\text{-NN}\) com pixels brutos em uma base pequena e pré-alinhada. A grande vantagem conceitual reside no aprendizado de representação: em vez de depender de descritores projetados manualmente (handcrafted features) ou de manter todo o conjunto de dados em memória para a busca por vizinhos no momento da inferência, a CNN otimiza automaticamente seus próprios filtros convolucionais durante o treinamento, gerando um modelo compacto capaz de realizar a extração de características e a classificação de forma integrada (end-to-end).

import matplotlib.pyplot as plt

# Valores obtidos no Capítulo 7 (k-NN, k=3), reproduzidos para comparação direta
ACC_KNN_PIXELS_CAP7 = 0.9844
ACC_KNN_HOG_CAP7 = 0.7578

metodos = ["k-NN\n(pixels brutos)", "k-NN\n(HOG)", "CNN\n(este capítulo)"]
acuracias = [ACC_KNN_PIXELS_CAP7, ACC_KNN_HOG_CAP7, acc_final_cnn]

plt.figure(figsize=(5, 4))
cores = ["#6366f1", "#f97316", "#16a34a"]
plt.bar(metodos, acuracias, color=cores)
plt.ylim(0, max(acuracias) + 0.08)
plt.ylabel("Acurácia (conjunto de teste)")
plt.title("Cap. 7 vs. Cap. 9 — Base de Dígitos")

for i, v in enumerate(acuracias):
    plt.text(i, v + 0.02, f"{v:.3f}", ha="center")

plt.tight_layout()
plt.show()
Figura 9.19: Comparação de acurácia entre os classificadores clássicos do Capítulo 7 (pixels brutos e HOG com k-NN) e a CNN treinada neste capítulo, na mesma base de dígitos.
Nota🧠 Por que funciona? — E por que a CNN nem sempre “ganha”

O resultado observado aqui repete o padrão já visto no Capítulo 7: a CNN, apesar de aprender automaticamente suas características, não supera necessariamente o \(k\text{-NN}\) com pixels brutos nesta base específica. A explicação é a mesma: load_digits é uma base pequena (menos de \(1.800\) exemplos), com imagens já centralizadas, normalizadas e de baixíssima resolução (\(8 \times 8\)) — condições em que a comparação direta de intensidades já é altamente informativa, e há poucos dados para que a rede aprenda filtros verdadeiramente superiores aos descritores simples.

O verdadeiro diferencial das CNNs aparece em cenários que descritores artesanais e classificadores simples não conseguem endereçar: imagens maiores e mais realistas, com milhares de categorias, variação substancial de pose, iluminação e fundo, e conjuntos de treinamento massivos — exatamente o regime em que os modelos apresentados na seção “Aplicações em Larga Escala”, mais adiante, foram treinados. A lição pedagógica que atravessa os Capítulos 7, 8 e 9 deste livro é consistente: a sofisticação de um método deve ser proporcional à complexidade do problema — usar uma CNN para um problema que um \(k\text{-NN}\) resolve igualmente bem é desperdício de recursos computacionais, não uma virtude.

Essa mesma proporcionalidade vale para as ferramentas de inspeção usadas ao longo do capítulo. O mm.showNet foi construído para fins didáticos e funciona bem em redes rasas como a CNNDigitos, mas não escala para arquiteturas profundas: cada camada rastreada vira uma coluna na figura, e camadas convolucionais com centenas de canais geram mosaicos grandes demais para interpretação visual; além disso, os hooks armazenam todas as ativações em memória, e o layout assume um fluxo sequencial, não representando fielmente conexões residuais ou ramificações (como em ResNets ou módulos Inception). Assim, showNet deve ser entendido como uma lente pedagógica para redes pequenas — análoga ao papel de mm.showBoundBox na depuração visual de detecções — e não como substituto de ferramentas voltadas à produção, como TensorBoard ou torchviz.

9.5.1.2 Transferência de Aprendizado

Treinar uma CNN do zero geralmente requer uma grande quantidade de dados rotulados e recursos computacionais significativos, pois o processo de treinamento precisa ajustar todos os parâmetros da rede. Em muitas aplicações, entretanto, apenas um conjunto reduzido de dados está disponível para a tarefa de interesse. Nessa situação, a transferência de aprendizado (transfer learning) reutiliza as representações aprendidas por um modelo previamente treinado em uma tarefa de origem com grande volume de dados, reduzindo o custo de treinamento e a necessidade de novas amostras.

Na VC, essa estratégia explora a organização hierárquica das CNNs. As camadas iniciais aprendem características visuais de baixo nível, como bordas, texturas, gradientes de intensidade e padrões de cores, que permanecem úteis em diferentes domínios. As camadas mais profundas combinam essas informações para formar representações progressivamente mais abstratas e especializadas, relacionadas às classes presentes na base de treinamento.

Esta seção investiga em quais condições a transferência de aprendizado produz bons resultados. O primeiro experimento mostra que um extrator pequeno e treinado em um domínio restrito pode levar à transferência negativa (negative transfer). O segundo demonstra por que modelos profundos pré-treinados em grandes bases de imagens alcançam elevado desempenho em novas tarefas. Por fim, o terceiro aplica essa estratégia a um problema de diagnóstico fitossanitário, ilustrando um cenário próximo de aplicações reais.

9.5.1.2.1 Experimento 1 — Limitações de um Extrator Pequeno e Especializado

O primeiro experimento mostra que a transferência de aprendizado nem sempre melhora o desempenho de um modelo. Para isso, o conjunto de dígitos manuscritos (load_digits) é dividido em dois domínios disjuntos:

  • Domínio A (origem): dígitos \(0\) a \(4\), utilizados para treinar uma pequena CNN;
  • Domínio B (destino): dígitos \(5\) a \(9\), reindexados para \(0\) a \(4\), formando uma nova tarefa com apenas \(20\) amostras de treinamento.

O objetivo consiste em avaliar o efeito de reutilizar o extrator de características aprendido no Domínio A sem permitir sua adaptação ao Domínio B.

9.5.1.2.1.1 Bloco 1: Divisão dos Domínios, Escassez e Exibição das Amostras

Este bloco prepara o conjunto de dados para o experimento. Diferentemente do projeto anterior, que utilizou todos os dígitos em um único problema de classificação, a base é dividida em duas tarefas independentes: uma tarefa de origem (Domínio A) e uma tarefa de destino (Domínio B).

A Figura 9.20 apresenta exemplos dos dois domínios após a separação das classes, a conversão para tensors do PyTorch e o pré-processamento.

  1. Separação das classes: As máscaras booleanas mask_A e mask_B separam os exemplos de cada domínio. Em seguida, o código reindexa os rótulos do Domínio B (y[mask_B] - 5) para o intervalo $[0,4]`, permitindo que ambos os modelos utilizem cinco classes de saída.

  2. Escassez de dados: O gerador np.random.default_rng(0) seleciona apenas \(20\) amostras para o treinamento do Domínio B, aproximadamente quatro por classe, simulando um cenário em que o treinamento do zero tende a sofrer com overfitting.

  3. Conversão para tensors: A função para_tensor converte as imagens para o formato \((N,1,8,8)\) e os rótulos para torch.long, compatíveis com as camadas nn.Conv2d e a função de perda.

  4. Visualização das amostras: O código utiliza .squeeze().numpy() para converter os tensors em matrizes do NumPy. A Figura 9.20 apresenta exemplos dos dois domínios e evidencia a reindexação aplicada aos rótulos do Domínio B.

# 1. Divisão do dataset em dois domínios disjuntos
classes_A, classes_B = [0, 1, 2, 3, 4], [5, 6, 7, 8, 9]
mask_A, mask_B = np.isin(y, classes_A), np.isin(y, classes_B)

XA, yA = X[mask_A], y[mask_A]
XB, yB = X[mask_B], y[mask_B] - 5  # Reindexação dos rótulos para o intervalo [0, 4]

# Divisão em treino e teste para ambos os domínios
XA_tr, XA_te, yA_tr, yA_te = train_test_split(
    XA, yA, test_size=0.25, random_state=42, stratify=yA
)
XB_tr, XB_te, yB_tr, yB_te = train_test_split(
    XB, yB, test_size=0.25, random_state=42, stratify=yB
)

# Simulação de escassez extrema no domínio de destino: apenas 20 amostras de treino
rng = np.random.default_rng(0)
idx_poucos = rng.choice(len(XB_tr), size=20, replace=False)
XB_tr_poucos, yB_tr_poucos = XB_tr[idx_poucos], yB_tr[idx_poucos]

# Função auxiliar para conversão em tensores PyTorch
def para_tensor(Ximg, yarr):
    return torch.tensor(Ximg).unsqueeze(1), torch.tensor(yarr, dtype=torch.long)

XA_tr_t, yA_tr_t = para_tensor(XA_tr, yA_tr)
XA_te_t, yA_te_t = para_tensor(XA_te, yA_te)
XB_tr_t, yB_tr_t = para_tensor(XB_tr_poucos, yB_tr_poucos)
XB_te_t, yB_te_t = para_tensor(XB_te, yB_te)

# Exibição de amostras de ambos os domínios
n_amostras = 5
imgs_A = [img.squeeze().numpy() for img in XA_tr_t[:n_amostras]]
titles_A = [f"A: {label.item()}" for label in yA_tr_t[:n_amostras]]

imgs_B = [img.squeeze().numpy() for img in XB_tr_t[:n_amostras]]
titles_B = [f"B: {label.item()} (orig: {label.item()+5})" for label in yB_tr_t[:n_amostras]]

mm.show(
    imgs_A + imgs_B,
    titles=titles_A + titles_B,
    cols=n_amostras,
    figsize=(12, 4.5)
)
Figura 9.20: Amostras dos conjuntos de treinamento após pré-processamento e adequação dimensional aos tensores PyTorch: Domínio A (dígitos 0 a 4, tarefa de origem) e Domínio B (dígitos 5 a 9 reindexados para 0 a 4, tarefa de destino).
9.5.1.2.1.2 Bloco 2: Arquitetura Modular e Rotinas Genéricas

Para viabilizar a transferência de aprendizado, a arquitetura convolucional e o laço de treinamento foram refatorados em relação à classe CNNDigitos do projeto anterior.

  1. Modularização da Arquitetura (Diferença para CNNDigitos):

    • No projeto anterior, a classe CNNDigitos declarava todas as camadas (conv1, conv2, pool, fc1, fc2) como membros diretos de uma única classe monolítica.
    • Aqui, a arquitetura é separada em dois componentes: a classe ExtratorConv encapsula o bloco espacial convolucional (\(2\) convoluções \(3 \times 3\), \(2\) Max-Poolings \(2 \times 2\) e o achatamento para \(64\) elementos), enquanto a classe CNNCompleta instancia esse extrator em self.extrator e anexa a “cabeça” classificadora (fc1 e fc2).
    • Essa separação é o que permite copiar o estado interno do extrator (state_dict()) de um modelo para outro de forma isolada.
  2. Ajuste no Número de Classes de Saída: Enquanto CNNDigitos no projeto anterior possuía \(10\) logits na camada de saída (self.fc2 = nn.Linear(32, 10)), a classe CNNCompleta recebe n_classes=5 no construtor para adequar-se à divisão dos domínios \(A\) e \(B\).

  3. Flexibilização do Laço de Treinamento (treinar):

    • No projeto anterior, o laço de treinamento iterava diretamente sobre os atributos globais do modelo (modelo_cnn.parameters()) e calculava métricas específicas em linha.
    • A função treinar abstrai esse processo e introduz o parâmetro opcional parametros. Se fornecido, o otimizador Adam atualiza apenas os parâmetros dessa lista, ignorando as camadas cujos gradientes foram desativados. Essa flexibilidade é crucial para executar o treinamento com congelamento parcial da rede.
  4. Isolamento da Avaliação (calcular_acuracia): Assim como feito na fase de teste do projeto anterior, a função coloca o modelo em eval() e utiliza o contexto torch.no_grad() para desativar o autograd, calculando a acurácia via .argmax(dim=1).

# Definição do bloco convolucional reaproveitável (mesma extração do projeto anterior)
class ExtratorConv(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv1 = nn.Conv2d(1, 8, 3, padding=1)
        self.conv2 = nn.Conv2d(8, 16, 3, padding=1)
        self.pool = nn.MaxPool2d(2, 2)
        self.relu = nn.ReLU()

    def forward(self, x):
        x = self.pool(self.relu(self.conv1(x)))
        x = self.pool(self.relu(self.conv2(x)))
        return x.view(x.size(0), -1)

# Arquitetura modular combinando o extrator e a cabeça classificadora
class CNNCompleta(nn.Module):
    def __init__(self, n_classes=5):
        super().__init__()
        self.extrator = ExtratorConv()
        self.fc1 = nn.Linear(64, 32)
        self.fc2 = nn.Linear(32, n_classes)
        self.relu = nn.ReLU()

    def forward(self, x):
        x = self.extrator(x)
        x = self.relu(self.fc1(x))
        return self.fc2(x)

# Rotina genérica de treinamento com otimização seletiva de parâmetros
def treinar(modelo, X_t, y_t, epocas, lr, tam_lote=16, parametros=None):
    # Parâmetros treináveis
    params = parametros if parametros is not None else modelo.parameters()  
    otim = optim.Adam(params, lr=lr)               # Otimizador Adam
    crit = nn.CrossEntropyLoss()                   # Função de perda
    n_amostras = X_t.size(0)                       # Número de amostras
    for _ in range(epocas):                        # Repete por época
        perm = torch.randperm(n_amostras)          # Embaralha amostras
        for i in range(0, n_amostras, tam_lote):   # Percorre mini-batches
            idx = perm[i:i + tam_lote]             # Índices do lote
            otim.zero_grad()                       # Zera gradientes
            perda = crit(modelo(X_t[idx]), y_t[idx])  # Calcula perda
            perda.backward()                       # Retropropagação
            otim.step()                            # Atualiza pesos

# Rotina de avaliação
def calcular_acuracia(modelo, X_t, y_t):
    modelo.eval()                                  # Modo avaliação
    with torch.no_grad():                          # Sem gradientes
        pred = modelo(X_t).argmax(dim=1)           # Classes preditas
    return (pred == y_t).float().mean().item()     # Retorna acurácia
9.5.1.2.1.3 Bloco 3: Pré-treinamento, Transferência e Análise Comparativa

Este bloco executa a comparação entre o ajuste do modelo a partir do zero e a aplicação da transferência com congelamento estático do extrator. Para dar total transparência ao experimento, os tamanhos dos conjuntos de treino e teste de ambos os domínios são impressos no terminal.

  1. Quantificação das Amostras por Domínio:

    • Domínio A (origem, dígitos \(0\) a \(4\)): Conta com \(675\) amostras de treino (\(75\%\)) e \(226\) de teste (\(25\%\)), fornecendo dados abundantes para que o modelo_origem aprenda o extrator convolucional até atingir \(100\%\) de acurácia.
    • Domínio B (destino, dígitos \(5\) a \(9\)): Possui \(224\) amostras de teste no total, mas seu conjunto de treino é intencionalmente reduzido de \(672\) para apenas \(20\) amostras (XB_tr_poucos), criando um cenário severo de escassez de dados.
  2. Etapa 1: Pré-treinamento no Domínio A (Origem): O modelo_origem é treinado do zero sobre as \(675\) amostras dos dígitos \(0\) a \(4\). Durante \(40\) épocas, o extrator convolucional ajusta seus filtros para identificar os traços característicos desses cinco primeiros dígitos, atingindo \(100\%\) de acurácia no conjunto de teste (\(226\) amostras).

  3. Etapa 2: Transferência de Pesos e Congelamento:

    • Cria-se o modelo_transferencia para resolver a tarefa do Domínio B (dígitos \(5\) a \(9\)).

    • Os pesos aprendidos no Domínio A são copiados via:

      • load_state_dict(modelo_origem.extrator.state_dict())
    • Congelamento: O laço for p in modelo_transferencia.extrator.parameters(): p.requires_grad = False desativa o cálculo de gradientes nas camadas convolucionais.

    • Treinamento Seletivo: A chamada treinar(...) passa estritamente os parâmetros das camadas densas (params_cabeca), ajustando a cabeça de classificação com apenas as \(20\) amostras de treino.

  4. Etapa 3: Treinamento do Zero no Domínio B (Controle Experimental): O modelo_do_zero possui a mesma arquitetura, mas é treinado do zero sobre as mesmas \(20\) amostras do Domínio B, sem qualquer reaproveitamento de pesos, pelas mesmas \(40\) épocas.

  5. Análise dos Resultados (Figura 9.21):

    • Com Transferência Congelada (\(72,77\%\)): Ao reaproveitar o extrator treinado no Domínio A e congelar seus parâmetros, a rede alcança \(72,77\%\) de acurácia no teste (\(224\) amostras) ajustando apenas as camadas densas.
    • Treinado do Zero (\(76,79\%\)): O treinamento do zero supera a transferência congelada no conjunto de teste do Domínio B.
    • Causa da Diferença: Por se tratar de um modelo minúsculo (apenas \(16\) filtros convolucionais em matrizes de \(8 \times 8\)), o extrator treinado no Domínio A tornou-se hiperespecializado nas formas geométricas dos dígitos \(0\) a \(4\). Ao congelar rigidamente esses poucos filtros, o modelo de destino ficou limitado a detectores inadequados para \(5\) a \(9\). A rede treinada do zero, mesmo com apenas \(20\) amostras, conseguiu adaptar seus \(16\) filtros diretamente aos traços do Domínio B.
# Fixar semente para reprodutibilidade
torch.manual_seed(42)

# Exibição do tamanho dos grupos de treino e teste
print("=== Detalhamento do Tamanho das Bases ===")
print(f"Domínio A (0-4) — Treino: {len(XA_tr_t)} amostras | Teste: {len(XA_te_t)} amostras")
print(f"Domínio B (5-9) — Treino completo: {len(XB_tr)} | Treino reduzido: {len(XB_tr_poucos)}",
      f"| Teste: {len(XB_te_t)} amostras\n")

# 1. Pré-treinamento na tarefa de origem (Domínio A: dígitos 0-4)
modelo_origem = CNNCompleta(n_classes=5)                       # Cria CNN

#######
treinar(modelo_origem, XA_tr_t, yA_tr_t, epocas=40, lr=1e-2)   # Treina modelo
         
acc_A = calcular_acuracia(modelo_origem, XA_te_t, yA_te_t)     # Mede acurácia
                          
print(f"Acurácia no domínio de origem A "                      # Exibe resultado
      f"(dígitos 0-4, {len(XA_te_t)} testes): " f"{acc_A:.4f}")

# 2. Transferência de Aprendizado (Extrator Congelado)
modelo_transferencia = CNNCompleta(n_classes=5)        # Cria CNN
modelo_transferencia.extrator.load_state_dict(         # Copia extrator
    modelo_origem.extrator.state_dict())

for p in modelo_transferencia.extrator.parameters():   # Percorre extrator
    p.requires_grad = False                            # Congela pesos

params_cabeca = list(modelo_transferencia.fc1.parameters())  # FC1
params_cabeca += list(modelo_transferencia.fc2.parameters()) # +FC2

#######
treinar(modelo_transferencia, XB_tr_t, yB_tr_t,              # Treina cabeça
         epocas=40, lr=1e-2, parametros=params_cabeca)

acc_transferencia = calcular_acuracia(modelo_transferencia, XB_te_t, yB_te_t) # Mede acurácia

# 3. Treinamento do Zero no Domínio B
modelo_do_zero = CNNCompleta(n_classes=5)                     # Cria CNN

#######
treinar(modelo_do_zero, XB_tr_t, yB_tr_t, epocas=40, lr=1e-2) # Treina modelo
         
acc_do_zero = calcular_acuracia(modelo_do_zero,  XB_te_t, yB_te_t) # Mede acurácia
                               

print(f"Domínio de destino B (dígitos 5-9), apenas {len(XB_tr_poucos)} ", 
      f"exemplos de treino ({len(XB_te_t)} testes):")
print(f"  Com transferência (extrator congelado): {acc_transferencia:.4f}")
print(f"  Treinando do zero (mesmos dados/épocas): {acc_do_zero:.4f}")

# Visualização comparativa
plt.figure(figsize=(4.5, 4))
plt.bar(["Do zero", "Transferência"], [acc_do_zero, acc_transferencia], 
        color=["#dc2626", "#16a34a"])
plt.ylim(0, max([acc_do_zero, acc_transferencia]) + 0.1)
plt.ylabel("Acurácia no domínio B (teste)")
plt.title(f"Efeito da Transferência ({len(XB_tr_poucos)} exemplos de treino)")

for i, v in enumerate([acc_do_zero, acc_transferencia]):
    plt.text(i, v + 0.02, f"{v:.3f}", ha="center")

plt.tight_layout()
plt.show()
=== Detalhamento do Tamanho das Bases ===
Domínio A (0-4) — Treino: 675 amostras | Teste: 226 amostras
Domínio B (5-9) — Treino completo: 672 | Treino reduzido: 20 | Teste: 224 amostras

Acurácia no domínio de origem A (dígitos 0-4, 226 testes): 1.0000
Domínio de destino B (dígitos 5-9), apenas 20  exemplos de treino (224 testes):
  Com transferência (extrator congelado): 0.7277
  Treinando do zero (mesmos dados/épocas): 0.7679
Figura 9.21: Comparação de acurácia no conjunto de teste do Domínio B (dígitos 5 a 9) sob restrição de dados (20 exemplos de treino): demonstração do impacto do congelamento rígido e da transferência negativa em redes de baixa capacidade.
9.5.1.2.1.4 Bloco 4: Visualização do Fluxo de Ativações com mm.showNet

Para confirmar que a extração de características reutilizada preserva as transformações dimensionais estudadas no projeto anterior, utiliza-se novamente a função mm.showNet da biblioteca morph. A Figura 9.22 exibe o fluxo de ativações do modelo_transferencia ao processar uma amostra do Domínio B (dígito \(7\), reindexado para a classe \(2\)).

  1. Preservação do Fluxo Convolucional: Como a arquitetura ExtratorConv replica as mesmas camadas de convolução e pooling da CNNDigitos do projeto anterior, as dimensões dos tensors intermediários mantêm-se em \((1, 8, 4, 4)\) no primeiro bloco.
  2. Inspeção da Cabeça Adaptada: A diferença em relação ao projeto anterior surge na camada de saída (fc2): enquanto o modelo do projeto anterior projetava o vetor intermediário em \(10\) logits (classes de \(0\) a \(9\)), o modelo de transferência projeta o vetor em \(5\) logits (classes de \(0\) a \(4\)), capturando as probabilidades relativas do Domínio B.
# Seleciona a primeira amostra de teste do Domínio B
x_amostra_B = XB_te_t[0:1]  # Tensor de dimensão (1, 1, 8, 8)
classe_verdadeira = yB_te_t[0].item()
classe_original = classe_verdadeira + 5

# Inspeção do fluxo de ativações no modelo de transferência
acts_transfer = mm.showNet(
    modelo_transferencia,
    x_amostra_B,
    titulo="Fluxo de ativações no modelo de transferência (Domínio B)",
    subtitulo=f"Amostra do dígito {classe_original} (rótulo reindexado: {classe_verdadeira})",
)

print("Camadas capturadas no modelo de transferência:\n", list(acts_transfer.keys()))
Figura 9.22: Fluxo de ativações e transformações dimensionais dos tensores no modelo de transferência de aprendizado ao processar uma amostra de teste do Domínio B (dígito 7, reindexado para classe 2).
Camadas capturadas no modelo de transferência:
 ['extrator.conv1', 'extrator.pool', 'extrator.conv2', 'extrator.pool #2', 'fc1', 'fc2']
9.5.1.2.1.5 Análise do Experimento 1

O modelo treinado do zero alcança acurácia superior ao modelo com transferência de aprendizado e extrator congelado. Esse resultado caracteriza um caso de transferência negativa (negative transfer) e decorre de três fatores:

  1. Baixa capacidade: O extrator possui apenas \(16\) filtros \(3 \times 3\), insuficientes para aprender representações generalizáveis.

  2. Especialização no domínio: O treinamento com os dígitos \(0\) a \(4\) produz filtros pouco discriminativos para os dígitos \(5\) a \(9\).

  3. Ausência de adaptação: O congelamento impede que o extrator ajuste seus filtros à nova tarefa.

Nota💡 Provocação Pedagógica

Este experimento utiliza um extrator pequeno treinado em um domínio restrito. O resultado seria diferente se o extrator tivesse aprendido suas representações em uma base com milhões de imagens e grande diversidade de objetos?

9.5.1.2.2 Experimento 2 — Quando a Transferência Realmente Funciona (ResNet-18 Pré-treinada)

O segundo experimento repete a mesma estrutura do primeiro — poucos exemplos de treino, duas classes, comparação entre estratégias —, mas troca o extrator artesanal de \(16\) filtros pela ResNet-18, uma arquitetura de \(18\) camadas pré-treinada na ImageNet (\(1,4\) milhão de imagens, \(1.000\) categorias), e o dataset sintético de dígitos por fotografias reais do Oxford-IIIT Pet Dataset (Parkhi et al., 2012).

A tarefa: distinguir duas raças caninas — Pug e Boxer — a partir de apenas \(15\) fotografias de treino por classe.

Dica🐶 Por que este cenário?

O desafio aqui não é a semelhança visual entre as raças — Pug e Boxer têm portes e proporções bem distintos —, e sim a escassez de dados: apenas \(30\) fotografias reais no total, sem qualquer imagem sintética. É o tipo de problema de baixo orçamento de dados que motiva, na prática, o uso de redes pré-treinadas: não há tempo nem recursos para fotografar e rotular milhares de cães antes de treinar um classificador do zero.

9.5.1.2.2.1 Bloco 1: Carregamento do Dataset Real e Amostragem Escassa
  1. Fonte: o Oxford-IIIT Pet Dataset (Parkhi et al., 2012) é carregado via torchvision.datasets.OxfordIIITPet, que baixa automaticamente as \(7.349\) fotografias e seus rótulos de raça na primeira execução.
  2. Filtragem: apenas as duas raças de interesse (Pug, Boxer) são mantidas.
  3. Escassez deliberada: apenas \(15\) fotografias de treino por classe (\(30\) no total) são sorteadas — o restante compõe o conjunto de teste, usado exclusivamente para avaliação.

A Figura 9.23 exibe amostras de treino de cada raça.

import random

RACAS_ALVO = ["Pug", "Boxer"]
N_TREINO_POR_CLASSE = 15
N_TESTE_POR_CLASSE = 20

# 1. Download do dataset completo (37 raças) — licença CC BY-SA 4.0
pets_completo = OxfordIIITPet(
    root="dados_pets", split="trainval", target_types="category", download=True
)
nomes_racas = pets_completo.classes
indices_alvo = [nomes_racas.index(r) for r in RACAS_ALVO]

# 2. Filtragem das duas raças de interesse, separadas por classe
por_classe = {idx: [] for idx in indices_alvo}
for img, lbl in pets_completo:
    if lbl in indices_alvo:
        por_classe[lbl].append(img)

# 3. Amostragem: poucas imagens de treino, mais imagens de teste
rng = random.Random(42)
imgs_treino, y_treino, imgs_teste, y_teste = [], [], [], []
for classe_idx, idx_original in enumerate(indices_alvo):
    imgs_raca = por_classe[idx_original][:]
    rng.shuffle(imgs_raca)
    imgs_treino += imgs_raca[:N_TREINO_POR_CLASSE]
    y_treino += [classe_idx] * N_TREINO_POR_CLASSE
    imgs_teste += imgs_raca[N_TREINO_POR_CLASSE : N_TREINO_POR_CLASSE + N_TESTE_POR_CLASSE]
    y_teste += [classe_idx] * N_TESTE_POR_CLASSE

print(f"Treino: {len(imgs_treino)} imagens | Teste: {len(imgs_teste)} imagens")

amostras_pil = imgs_treino[:4] + imgs_treino[N_TREINO_POR_CLASSE:N_TREINO_POR_CLASSE + 4]
amostras_exibicao = [np.array(img.convert("RGB")) for img in amostras_pil]  # PIL -> ndarray
titulos_exibicao = [RACAS_ALVO[0]] * 4 + [RACAS_ALVO[1]] * 4
mm.show(amostras_exibicao, titles=titulos_exibicao, cols=4, figsize=(11, 6))
Treino: 30 imagens | Teste: 40 imagens
Figura 9.23: Amostras reais de treino do Oxford-IIIT Pet Dataset (Parkhi et al., 2012, licença CC BY-SA 4.0): Pug e Boxer, raças escolhidas para simular um cenário real de escassez de dados.
9.5.1.2.2.2 Bloco 2: Três Estratégias sobre a Mesma Arquitetura

Para isolar o efeito da transferência de aprendizado, as três estratégias reutilizam exatamente a mesma arquitetura (ResNet-18), variando apenas a origem dos pesos e quais parâmetros permanecem treináveis:

  1. do_zero: pesos aleatórios (weights=None) — equivalente a treinar a arquitetura da ResNet-18 inteiramente do zero, como no Bloco 2 do Experimento 1.
  2. congelado: pesos pré-treinados na ImageNet, com requires_grad = False em todas as camadas convolucionais — apenas a nova camada final é treinada.
  3. fine_tuning: pesos pré-treinados na ImageNet como ponto de partida, mas sem congelamento — toda a rede se ajusta ao novo domínio, com taxa de aprendizado baixa para não destruir o conhecimento prévio.

Em todos os casos, a camada final fc é substituída por nn.Linear(fc.in_features, 2), correspondente às duas raças de destino.

transformacao_resnet = T.Compose([
    T.Resize((224, 224)),
    T.ToTensor(),
    T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
])

def prepara_tensores(imgs, labels):
    X = torch.stack([transformacao_resnet(img.convert("RGB")) for img in imgs])
    y = torch.tensor(labels, dtype=torch.long)
    return X, y

X_tr, y_tr = prepara_tensores(imgs_treino, y_treino)
X_te, y_te = prepara_tensores(imgs_teste, y_teste)

def cria_modelo_pets(estrategia):
    pesos = None if estrategia == "do_zero" else models.ResNet18_Weights.DEFAULT
    modelo = models.resnet18(weights=pesos)
    if estrategia == "congelado":
        for p in modelo.parameters():
            p.requires_grad = False
    modelo.fc = nn.Linear(modelo.fc.in_features, len(RACAS_ALVO))
    return modelo

modelo_do_zero = cria_modelo_pets("do_zero")
modelo_congelado = cria_modelo_pets("congelado")
modelo_fine_tuning = cria_modelo_pets("fine_tuning")
9.5.1.2.2.3 Bloco 3: Treinamento Comparativo e Análise de Acurácia

Reutilizando as funções genéricas treinar e calcular_acuracia, definidas no Bloco 2 do Experimento 1, os três modelos são treinados sobre o mesmo conjunto de \(30\) fotografias e avaliados no conjunto de teste (imagens nunca vistas durante o treino):

  • O modelo do_zero tende a superajustar rapidamente às \(30\) fotografias de treino, sem generalizar para o conjunto de teste — \(30\) exemplos são drasticamente insuficientes para ajustar os \(11\) milhões de parâmetros da ResNet-18 a partir do zero.
  • O modelo congelado já deve alcançar uma acurácia consideravelmente superior, pois reaproveita, sem qualquer ajuste, características visuais genéricas (bordas, texturas, contornos) aprendidas na ImageNet — apenas a nova camada linear precisa ser ajustada às \(30\) fotografias.
  • O modelo fine_tuning tende a igualar ou superar o extrator congelado, pois parte do mesmo conhecimento prévio, mas ainda permite um ajuste fino de toda a rede às particularidades visuais das raças.

A Figura 9.24 resume os três resultados.

torch.manual_seed(42)

configuracoes = [
    ("Do zero",              modelo_do_zero,      None, 2e-3),
    ("Extrator congelado",   modelo_congelado,    "fc", 1e-3),
    ("Fine-tuning completo", modelo_fine_tuning,  None, 1e-4),
]

resultados_pets = {}
for nome, modelo, alvo_params, taxa in configuracoes:
    parametros = modelo.fc.parameters() if alvo_params == "fc" else None
    treinar(modelo, X_tr, y_tr, epocas=15, lr=taxa, tam_lote=8, parametros=parametros)
    resultados_pets[nome] = calcular_acuracia(modelo, X_te, y_te)
    print(f"{nome}: {resultados_pets[nome]*100:.1f}%")

plt.figure(figsize=(5.5, 4))
cores = ["#dc2626", "#f59e0b", "#16a34a"]
plt.bar(resultados_pets.keys(), resultados_pets.values(), color=cores)
plt.ylim(0, 1.05)
plt.axhline(0.5, color="gray", linestyle="--", linewidth=1, label="Chute aleatório (50%)")
plt.ylabel("Acurácia no teste")
plt.title("Pug vs. Boxer — 15 fotos de treino/classe")
for i, v in enumerate(resultados_pets.values()):
    plt.text(i, v + 0.02, f"{v*100:.1f}%", ha="center")
plt.xticks(rotation=10)
plt.legend()
plt.tight_layout()
plt.show()
Do zero: 62.5%
Extrator congelado: 97.5%
Fine-tuning completo: 90.0%
Figura 9.24: Comparação de acurácia no conjunto de teste real (Pug vs. Boxer, 15 fotografias de treino por classe): do zero, extrator congelado e fine-tuning completo, todos sobre a mesma arquitetura ResNet-18.
9.5.1.2.2.4 Bloco 4: Inspeção Qualitativa das Predições

Como no Experimento 1 e na seção seguinte sobre diagnóstico foliar, é instrutivo observar individualmente algumas predições do melhor modelo (tipicamente fine_tuning ou congelado) sobre fotografias reais de teste, comparando o rótulo previsto com a raça real.

melhor_modelo = modelo_fine_tuning  # ou modelo_congelado, conforme o resultado do Bloco 3
melhor_modelo.eval()

idx_amostras = list(range(4)) + list(range(N_TESTE_POR_CLASSE, N_TESTE_POR_CLASSE + 4))

imgs_pred, titulos_pred = [], []
with torch.no_grad():
    for idx in idx_amostras:
        entrada = X_te[idx].unsqueeze(0)
        pred_idx = melhor_modelo(entrada).argmax(dim=1).item()
        real_idx = y_te[idx].item()
        marcador = "✓" if pred_idx == real_idx else "✗"
        imgs_pred.append(np.array(imgs_teste[idx].convert("RGB")))  # PIL -> ndarray
        titulos_pred.append(f"{marcador} previsto: {RACAS_ALVO[pred_idx]}\n"
                             f"real: {RACAS_ALVO[real_idx]}")

mm.show(imgs_pred, titles=titulos_pred, cols=4, figsize=(12, 7))
Figura 9.25: Predições do modelo com extrator pré-treinado (ResNet-18) em fotografias reais de teste: rótulo previsto vs. raça real, quatro amostras de cada classe.
# Limpeza explícita dos dados baixados e liberação de memória
if FLAG_LIMPAR_DADOS:
    if os.path.exists('./dados_pets'):
        shutil.rmtree('./dados_pets')
        print('🧹 Diretório de dados temporários ./dados_pets removido com sucesso.')

    if torch.cuda.is_available():
        torch.cuda.empty_cache()
9.5.1.2.3 Experimento 3 — Diagnóstico Fitossanitário com Transferência de Aprendizado

O experimento anterior mostrou que uma ResNet-18 pré-treinada na ImageNet pode adaptar-se a uma nova tarefa utilizando poucas amostras. Agora, a mesma estratégia é aplicada a um problema de diagnóstico fitossanitário. A ResNet-18 deve classificar imagens de folhas em três categorias: ["folha_saudavel", "folha_doente", "sintoma_desconhecido"].

  • folha_saudavel: folha sem lesões visíveis.
  • folha_doente: folha com manchas escuras que simulam uma doença fúngica.
  • sintoma_desconhecido: folha com clorose amarelada, representando um padrão diferente da doença conhecida.
Dica🌱 Por que este cenário?

O diagnóstico fitossanitário constitui uma importante aplicação da VC na agricultura de precisão. Um modelo pré-treinado na ImageNet pode reutilizar características como bordas, texturas e padrões de cor para aprender essa nova tarefa com poucas imagens.

9.5.1.2.3.1 Bloco 1: Geração do Dataset Sintético

Este bloco gera um conjunto sintético com 30 imagens por classe para treinamento e 8 para validação, totalizando 90 e 24 imagens, respectivamente.

  1. Geração da folha: A função desenha_folha_base cria o contorno da folha, variando tamanho, orientação e tonalidade de verde.

  2. Simulação da doença: A função aplica_manchas_doenca adiciona manchas escuras irregulares que simulam lesões fúngicas.

  3. Simulação de outro sintoma: A função aplica_sintoma_desconhecido adiciona regiões amareladas que representam um padrão distinto da doença conhecida.

  4. Visualização das amostras: A Figura 9.26 apresenta exemplos das três categorias do conjunto sintético.

CLASSES_FOLHA = ["folha_saudavel", "folha_doente", "sintoma_desconhecido"]


def desenha_folha_base(tam_img, rng):
    '''Desenha o contorno oval de uma folha verde com nervura central,
    com pequenas variações de tom, tamanho e orientação entre amostras.'''
    img = np.full((tam_img, tam_img, 3), 245, dtype=np.uint8)  # fundo claro
    cx, cy = tam_img // 2, tam_img // 2
    eixo_a = rng.randint(int(tam_img * 0.30), int(tam_img * 0.38))
    eixo_b = rng.randint(int(tam_img * 0.20), int(tam_img * 0.26))
    angulo = rng.uniform(-15, 15)
    verde = (rng.randint(40, 70), rng.randint(120, 160), rng.randint(40, 70))
    cv2.ellipse(img, (cx, cy), (eixo_a, eixo_b), angulo, 0, 360, verde, -1, cv2.LINE_AA)
    ang_rad = np.deg2rad(angulo)
    dx, dy = np.cos(ang_rad), np.sin(ang_rad)
    p1 = (int(cx - eixo_a * dx), int(cy - eixo_a * dy))
    p2 = (int(cx + eixo_a * dx), int(cy + eixo_a * dy))
    cv2.line(img, p1, p2, (25, 90, 25), 2, cv2.LINE_AA)  # nervura central
    return img, (cx, cy, eixo_a, eixo_b, angulo)


def aplica_manchas_doenca(img, centro_folha, rng, n_manchas=(4, 8)):
    '''Simula lesões foliares: manchas escuras de bordas irregulares
    (padrão típico de doenças fúngicas).'''
    cx, cy, eixo_a, eixo_b, _ = centro_folha
    for _ in range(rng.randint(*n_manchas)):
        raio = rng.randint(1, 3)
        px = cx + rng.randint(-int(eixo_a * 0.7), int(eixo_a * 0.7))
        py = cy + rng.randint(-int(eixo_b * 0.7), int(eixo_b * 0.7))
        cor_mancha = (rng.randint(50, 90), rng.randint(25, 45), rng.randint(10, 25))
        cv2.circle(img, (px, py), raio, cor_mancha, -1, cv2.LINE_AA)
        cv2.circle(img, (px, py), raio + 1, (120, 85, 30), 1, cv2.LINE_AA)  # halo
    return img


def aplica_sintoma_desconhecido(img, centro_folha, rng):
    '''Simula um padrão distinto (mosqueado amarelado/clorose), diferente
    das manchas escuras da doença conhecida.'''
    cx, cy, eixo_a, eixo_b, _ = centro_folha
    for _ in range(rng.randint(3, 5)):
        eixo_m = (rng.randint(1, 3), rng.randint(2, 3))
        px = cx + rng.randint(-int(eixo_a * 0.6), int(eixo_a * 0.6))
        py = cy + rng.randint(-int(eixo_b * 0.6), int(eixo_b * 0.6))
        cor_clorose = (rng.randint(200, 235), rng.randint(195, 225), rng.randint(50, 90))
        ang_m = rng.uniform(0, 180)
        cv2.ellipse(img, (px, py), eixo_m, ang_m, 0, 360, cor_clorose, -1, cv2.LINE_AA)
    return img


def aplica_ruido_sal_pimenta(img, prop_ruido=0.02, rng=None):
    '''Aplica ruído sal (pontos brancos) e pimenta (pontos pretos) aleatórios.
    prop_ruido: fração de pixels alterados (ex: 0.02 = 2% dos pixels).'''
    if prop_ruido <= 0:
        return img
    
    img_ruido = img.copy()
    num_pixels = int(prop_ruido * img.shape[0] * img.shape[1])
    n_sal = num_pixels // 2
    n_pimenta = num_pixels - n_sal

    # Aplica Sal (Branco - [255, 255, 255])
    for _ in range(n_sal):
        y = rng.randint(0, img.shape[0] - 1)
        x = rng.randint(0, img.shape[1] - 1)
        img_ruido[y, x] = [255, 255, 255]

    # Aplica Pimenta (Preto - [0, 0, 0])
    for _ in range(n_pimenta):
        y = rng.randint(0, img.shape[0] - 1)
        x = rng.randint(0, img.shape[1] - 1)
        img_ruido[y, x] = [0, 0, 0]

    return img_ruido


def gera_folha(classe_idx, tam_img=128, rng=None, prop_ruido=0.02):
    rng = rng or random.Random()
    img, geometria = desenha_folha_base(tam_img, rng)
    nome = CLASSES_FOLHA[classe_idx]
    
    if nome == "folha_doente":
        img = aplica_manchas_doenca(img, geometria, rng)
    elif nome == "sintoma_desconhecido":
        img = aplica_sintoma_desconhecido(img, geometria, rng)
        
    ruido_exp = rng.randint(-3, 3)  # leve variação de exposição
    img = np.clip(img.astype(np.int16) + ruido_exp, 0, 255).astype(np.uint8)
    
    # Aplicação do ruído Sal e Pimenta
    img = aplica_ruido_sal_pimenta(img, prop_ruido=prop_ruido, rng=rng)
    
    return img


def gera_conjunto(n_por_classe, tam_img=128, seed=0, prop_ruido=0.02):
    rng = random.Random(seed)
    imgs, labels = [], []
    for classe_idx in range(len(CLASSES_FOLHA)):
        for _ in range(n_por_classe):
          imgs.append(gera_folha(classe_idx, tam_img=tam_img, rng=rng, prop_ruido=prop_ruido))
          labels.append(classe_idx)
    return imgs, labels


N_POR_CLASSE_TREINO, N_POR_CLASSE_VAL = 30, 8

imgs_treino, labels_treino = gera_conjunto(n_por_classe=N_POR_CLASSE_TREINO, seed=42, 
                                           prop_ruido=0.02)
imgs_val, labels_val = gera_conjunto(n_por_classe=N_POR_CLASSE_VAL, seed=123, prop_ruido=0.02)

print(f"Treino: {len(imgs_treino)} imagens ({N_POR_CLASSE_TREINO} por classe) | "
      f"Validação: {len(imgs_val)} imagens ({N_POR_CLASSE_VAL} por classe)")

# Exibição de 2 amostras de cada classe (6 imagens no total)
amostras_exibir, titulos_exibir = [], []
for classe_idx, nome in enumerate(CLASSES_FOLHA):
    for k in range(2):
        idx = classe_idx * N_POR_CLASSE_TREINO + k
        amostras_exibir.append(imgs_treino[idx])
        titulos_exibir.append(nome)

mm.show(amostras_exibir, titles=titulos_exibir, cols=3, figsize=(10, 7))
Treino: 90 imagens (30 por classe) | Validação: 24 imagens (8 por classe)
Figura 9.26: Amostras sintéticas do dataset de diagnóstico foliar: folha saudável, folha doente (manchas escuras) e sintoma desconhecido (clorose amarelada) com ruído sal e pimenta.
Nota🧠 Armadilha Comum

A transferência de aprendizado exige que cada classe apresente padrões visuais distintos. Repetir a mesma imagem com rótulos diferentes impede que a camada classificadora aprenda uma fronteira de decisão, pois o extrator gera praticamente as mesmas características para todas as amostras.

Neste experimento, cada imagem é gerada de forma independente, com padrões visuais compatíveis com sua classe (folha saudável, lesões fúngicas ou clorose), fornecendo informações suficientes para o treinamento da camada classificadora.

9.5.1.2.3.2 Bloco 2: Preparação dos Tensores e Adaptação da Arquitetura

Modelos como a ResNet-18 exigem imagens coloridas de \(224 \times 224\) pixels normalizadas segundo as estatísticas da ImageNet (\(\mu = [0,485; 0,456; 0,406]\) e \(\sigma = [0,229; 0,224; 0,225]\)).

  1. Transformação de Entrada (transforms.Compose): aplica-se o redimensionamento e a normalização padrão a cada imagem do dataset sintético, produzindo os tensores X_treino/X_val e os rótulos y_treino/y_val — cada exemplo é uma imagem genuinamente distinta, associada ao rótulo correto de sua classe.
  2. Congelamento do Extrator: o laço for p in modelo_resnet.parameters(): p.requires_grad = False desativa os gradientes nas camadas convolucionais pré-treinadas.
  3. Nova Camada Final: a camada modelo_resnet.fc é substituída por uma nova instância nn.Linear(modelo_resnet.fc.in_features, n_classes_destino), recém-inicializada e com gradientes ativos por padrão. O número de características de entrada é obtido dinamicamente a partir da própria camada original (in_features, igual a \(512\) na ResNet-18), em vez de fixado manualmente no código — prática recomendada, pois torna o trecho reutilizável para outras variantes da arquitetura sem alterações.
# 1. Pipeline de transformações esperadas pela ResNet
transformacao_resnet = T.Compose([
    T.Resize((224, 224)),
    T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
])


def prepara_tensores(imgs, labels):
    tensores = [transformacao_resnet(T.functional.to_tensor(img)) for img in imgs]
    X = torch.stack(tensores)
    y = torch.tensor(labels, dtype=torch.long)
    return X, y


# 2. Conversão do dataset sintético (Bloco 1) em tensores normalizados
X_treino, y_treino = prepara_tensores(imgs_treino, labels_treino)
X_val, y_val = prepara_tensores(imgs_val, labels_val)

loader_treino = DataLoader(TensorDataset(X_treino, y_treino), batch_size=16, shuffle=True)
loader_val = DataLoader(TensorDataset(X_val, y_val), batch_size=16, shuffle=False)

# 3. Carregamento da ResNet-18 pré-treinada e congelamento do extrator
n_classes_destino = len(CLASSES_FOLHA)
modelo_resnet = models.resnet18(weights=models.ResNet18_Weights.DEFAULT)

for parametro in modelo_resnet.parameters():
    parametro.requires_grad = False

# 4. Substituição da camada final para as 3 novas classes de destino
modelo_resnet.fc = nn.Linear(modelo_resnet.fc.in_features, n_classes_destino)

print(f"Nova camada final: {modelo_resnet.fc}")
Nova camada final: Linear(in_features=512, out_features=3, bias=True)
9.5.1.2.3.3 Bloco 3: Laço de Treinamento e Avaliação

Com o extrator congelado e a nova camada de saída devidamente acoplada, executa-se o ajuste fino (fine-tuning) da nova cabeça de classificação.

  1. Otimização Focada: o otimizador Adam recebe estritamente modelo_resnet.fc.parameters(), atualizando apenas a nova camada de saída — o restante da rede permanece congelado, conforme definido no Bloco 2.
  2. Execução do Laço: a cada época, o modelo itera sobre os lotes de treino, calcula a perda por Entropia Cruzada e ajusta os pesos da camada final; em seguida, avalia-se a acurácia no conjunto de validação (imagens nunca vistas durante o treino).
  3. Curvas de Treinamento: a Figura 9.27 acompanha a evolução da perda de treino e da acurácia de validação ao longo das épocas — como as três classes são visualmente distintas entre si, espera-se convergência genuína, bem acima do patamar de \(33\%\) correspondente a um chute aleatório entre \(3\) classes.
dispositivo = torch.device("cuda" if torch.cuda.is_available() else "cpu")
modelo_resnet = modelo_resnet.to(dispositivo)

criterio = nn.CrossEntropyLoss()
otimizador = optim.Adam(modelo_resnet.fc.parameters(), lr=1e-3)

historico_perda, historico_acc = [], []
epocas = 10

for epoca in range(epocas):
    modelo_resnet.train()
    perda_acumulada, n_batches = 0.0, 0

    for X_batch, y_batch in loader_treino:
        X_batch, y_batch = X_batch.to(dispositivo), y_batch.to(dispositivo)

        otimizador.zero_grad()
        saidas = modelo_resnet(X_batch)
        perda = criterio(saidas, y_batch)
        perda.backward()
        otimizador.step()

        perda_acumulada += perda.item()
        n_batches += 1
    historico_perda.append(perda_acumulada / n_batches)

    modelo_resnet.eval()
    acertos, total = 0, 0
    with torch.no_grad():
        for X_batch, y_batch in loader_val:
            X_batch, y_batch = X_batch.to(dispositivo), y_batch.to(dispositivo)
            predicoes = modelo_resnet(X_batch).argmax(dim=1)
            acertos += (predicoes == y_batch).sum().item()
            total += y_batch.size(0)
    acc = acertos / total
    historico_acc.append(acc)

    print(f"Época {epoca+1}/{epocas} — perda: {historico_perda[-1]:.4f} — ",
          f" acurácia_val: {acc*100:.1f}%")

f=mm.showTrainCurves(
    historico_perda, historico_acc,
    titulo="Fine-Tuning da ResNet-18 — Diagnóstico Foliar",
    subtitulo="Apenas a nova camada linear (fc) é treinada; o extrator permanece congelado",
)
Época 1/10 — perda: 1.0099 —   acurácia_val: 33.3%
Época 2/10 — perda: 0.7550 —   acurácia_val: 66.7%
Época 3/10 — perda: 0.6041 —   acurácia_val: 95.8%
Época 4/10 — perda: 0.5879 —   acurácia_val: 95.8%
Época 5/10 — perda: 0.4108 —   acurácia_val: 95.8%
Época 6/10 — perda: 0.3432 —   acurácia_val: 100.0%
Época 7/10 — perda: 0.3048 —   acurácia_val: 100.0%
Época 8/10 — perda: 0.2615 —   acurácia_val: 100.0%
Época 9/10 — perda: 0.2418 —   acurácia_val: 100.0%
Época 10/10 — perda: 0.2384 —   acurácia_val: 100.0%
Figura 9.27: Curvas de treinamento do fine-tuning da ResNet-18 no dataset sintético de diagnóstico foliar: perda de treino e acurácia de validação ao longo das épocas.
9.5.1.2.3.4 Bloco 4: Inspeção Qualitativa das Predições

Além da curva de acurácia agregada, é instrutivo observar individualmente algumas predições do modelo sobre o conjunto de validação, comparando o rótulo previsto com o rótulo real. A Figura 9.28 exibe duas amostras de cada classe.

modelo_resnet.eval()

# Duas amostras de cada classe no conjunto de validação
idx_amostras = [0, N_POR_CLASSE_VAL, 2 * N_POR_CLASSE_VAL,
                1, N_POR_CLASSE_VAL + 1, 2 * N_POR_CLASSE_VAL + 1]

imgs_pred, titulos_pred = [], []
with torch.no_grad():
    for idx in idx_amostras:
        entrada = X_val[idx].unsqueeze(0).to(dispositivo)
        pred_idx = modelo_resnet(entrada).argmax(dim=1).item()
        real_idx = y_val[idx].item()
        marcador = "✓" if pred_idx == real_idx else "✗"
        imgs_pred.append(imgs_val[idx])
        titulos_pred.append(f"{marcador} previsto: {CLASSES_FOLHA[pred_idx]}\n"+
                            f"real: {CLASSES_FOLHA[real_idx]}")

mm.show(imgs_pred, titles=titulos_pred, cols=3, figsize=(10, 7))
Figura 9.28: Predições da ResNet-18 ajustada em amostras de validação: rótulo previsto vs. rótulo real, com duas amostras por classe.
9.5.1.2.3.5 Análise do Experimento 3

A ResNet-18 alcança alta acurácia mesmo utilizando um conjunto reduzido de imagens sintéticas. Esse resultado mostra que as representações aprendidas na ImageNet permanecem úteis em um domínio completamente diferente, exigindo apenas a adaptação da camada classificadora.

O experimento também ilustra uma situação comum em aplicações reais, nas quais a disponibilidade de dados rotulados é limitada. Nesses cenários, a transferência de aprendizado reduz o tempo de treinamento e permite obter modelos com bom desempenho mesmo sem treinar toda a rede.

Nota🧠 Síntese Comparativa — Quando a Transferência de Aprendizado Funciona?

Os três experimentos mostram que a transferência de aprendizado depende da capacidade de generalização do extrator de características.

  • Experimento 1: um extrator pequeno, treinado em um domínio restrito, aprende representações pouco generalizáveis e pode produzir transferência negativa.

  • Experimento 2: uma ResNet-18 pré-treinada na ImageNet transfere representações gerais para uma tarefa de classificação de raças de cães e gatos, alcançando alta acurácia com poucas amostras.

  • Experimento 3: a mesma estratégia adapta o modelo a um problema de diagnóstico fitossanitário, mostrando que um único extrator pode servir como base para diferentes domínios de aplicação.

9.5.1.2.4 Comparativo das Abordagens de Transferência

A Tabela 9.2 resume os resultados obtidos nos três experimentos.

Tabela 9.2: Comparação entre os três cenários de transferência de aprendizado apresentados nesta seção.
Aspecto Experimento 1 Experimento 2 Experimento 3
Extrator CNN pequena ResNet-18 ResNet-18
Treinamento do extrator Dígitos (\(0\)–\(4\)) ImageNet ImageNet
Capacidade de generalização Baixa Alta Alta
Nova tarefa Dígitos (\(5\)–\(9\)) Raças de cães e gatos Diagnóstico fitossanitário
Resultado Transferência negativa Transferência positiva Transferência positiva

9.5.2 Detecção de Objetos

Os experimentos anteriores mostraram como a transferência de aprendizado adapta modelos pré-treinados para tarefas de classificação de imagens. O mesmo princípio também fundamenta arquiteturas de detecção de objetos, nas quais um extrator de características pré-treinado fornece representações visuais gerais, enquanto módulos especializados localizam e classificam os objetos na imagem.

As próximas seções apresentam a Faster R-CNN como exemplo de detector pré-treinado usado diretamente para inferência e, em seguida, um experimento completo de ajuste fino com a arquitetura YOLO.

9.5.2.1 Faster R-CNN: Detector Pré-treinado

A detecção de objetos estende o uso de modelos pré-treinados para uma tarefa mais complexa que a classificação. A Faster R-CNN utiliza uma CNN pré-treinada, como a ResNet-50, como extrator de características (backbone) e acrescenta módulos especializados para localizar e classificar objetos.

Sobre esse extrator, a arquitetura incorpora duas “cabeças” principais:

  • Region Proposal Network (RPN): propõe regiões da imagem com alta probabilidade de conter objetos.
  • Cabeça classificadora: refina essas regiões, atribui uma classe a cada objeto e ajusta suas caixas delimitadoras.

O código a seguir utiliza uma Faster R-CNN com pesos pré-treinados no COCO para detectar objetos em uma imagem, produzindo suas classes, coordenadas e pontuações de confiança.

Nota🔍 Onde está a transferência de aprendizado aqui?

Diferentemente dos experimentos anteriores, este exemplo não realiza ajuste fino (fine-tuning). O modelo executa apenas a inferência (eval()), reutilizando diretamente os pesos do backbone, da RPN e da cabeça classificadora treinados no COCO.

A adaptação para um novo domínio exigiria substituir a camada box_predictor por uma nova cabeça de classificação, compatível com as classes da aplicação, e treiná-la sobre um conjunto de imagens anotadas. Esse procedimento segue o mesmo princípio apresentado na seção de transferência de aprendizado e constitui o fluxo usual para aplicações específicas, como detecção de pragas, defeitos de fabricação ou veículos.

  1. Categorias do COCO: O código recupera os nomes das classes a partir das meta-informações dos pesos (FasterRCNN_ResNet50_FPN_Weights.DEFAULT.meta["categories"]). Embora essa lista contenha \(91\) entradas por razões históricas do formato de anotação do COCO, apenas \(80\) correspondem a categorias de objetos.

  2. Inferência: A imagem carregada por mm.read() é convertida em tensor e processada pelo modelo em modo de avaliação (eval()). O código mantém apenas as detecções com confiança superior a \(80\%\).

  3. Anotação da imagem: Para cada objeto detectado, o código desenha a caixa delimitadora (cv2.rectangle) e escreve a classe predita e sua confiança (cv2.putText).

  4. Visualização: A Figura 9.29 apresenta a imagem anotada com as detecções realizadas pelo modelo.

# 1. Carregamento da imagem e dos nomes das categorias do COCO
url_imagem = "https://raw.githubusercontent.com/pytorch/hub/master/images/dog.jpg"
url_imagem = "http://images.cocodataset.org/val2017/000000039769.jpg"
img = mm.read(url_imagem)

pesos_coco = FasterRCNN_ResNet50_FPN_Weights.DEFAULT
categorias_coco = pesos_coco.meta["categories"]  # Mapeamento índice -> nome da classe

# 2. Carregamento do modelo Faster R-CNN pré-treinado
modelo_detection = fasterrcnn_resnet50_fpn(weights=pesos_coco).eval()

# 3. Execução da inferência sem cálculo de gradientes
with torch.no_grad():
    predicao = modelo_detection([to_tensor(img)])[0]

# 4. Filtragem das detecções com confiança superior a 80%
limiar_confianca = 0.8
mascara_confianca = predicao["scores"] >= limiar_confianca

caixas_filtradas = predicao["boxes"][mascara_confianca].numpy()
scores_filtrados = predicao["scores"][mascara_confianca].numpy()
labels_filtrados = predicao["labels"][mascara_confianca].numpy()

img_com_caixas = img.copy()

# 5. Desenho das caixas delimitadoras e dos rótulos de classe
for box, score, label_idx in zip(caixas_filtradas, scores_filtrados, labels_filtrados):
    x1, y1, x2, y2 = box.astype(int)
    nome_classe = categorias_coco[label_idx]
    texto_rotulo = f"{nome_classe}: {score:.2f}"

    # Desenha o retângulo vermelho (RGB: 255, 0, 0) com espessura de 3 pixels
    cv2.rectangle(img_com_caixas, (x1, y1), (x2, y2), (255, 0, 0), 3)

    # Escreve a classe e a confiança acima da caixa delimitadora
    cv2.putText(
        img_com_caixas,
        texto_rotulo,
        (x1, max(y1 - 10, 20)),
        cv2.FONT_HERSHEY_SIMPLEX,
        0.8,
        (255, 0, 0),
        2,
        cv2.LINE_AA,
    )

# 6. Exibição gráfica da imagem resultante
mm.show(img_com_caixas, title="Faster R-CNN (COCO) — Detecção com Classe e Confiança")
Figura 9.29: Resultado da inferência com o modelo Faster R-CNN pré-treinado no dataset COCO: identificação da classe, caixa delimitadora e pontuação de confiança sobrepostas.

9.5.2.2 Detecção de Objetos e Transferência de Aprendizado com YOLO

As seções anteriores aplicaram a transferência de aprendizado a problemas de classificação de imagens, em que o modelo associa um único rótulo à imagem inteira. Nesta seção, o mesmo princípio é estendido à detecção de objetos, tarefa que exige identificar simultaneamente o que está presente na imagem e onde cada objeto se encontra.

A subseção anterior apresentou a Faster R-CNN como exemplo de detector pré-treinado utilizado diretamente para inferência, sem qualquer adaptação ao novo domínio. Neste experimento, o modelo passa por uma etapa de ajuste fino (fine-tuning): parte-se de uma arquitetura YOLO (You Only Look Once) pré-treinada no conjunto COCO e adapta-se a rede para detectar e classificar objetos de um novo domínio.

Diferentemente da Faster R-CNN, que realiza a detecção em dois estágios, a família YOLO adota uma arquitetura de estágio único (single-stage detector), estimando, em uma única propagação pela rede, as caixas delimitadoras (bounding boxes), a confiança de cada detecção e a classe correspondente. Essa estratégia reduz o custo computacional e viabiliza aplicações em tempo real.

Como exemplo, o experimento utiliza um conjunto sintético de formas geométricas (triângulos, quadrados, estrelas, entre outras), com variações de cor, tamanho, rotação e degradação por ruído do tipo sal e pimenta.

9.5.2.2.1 Bloco 1: Geração do Dataset Sintético

O bloco a seguir gera um conjunto sintético para treinamento e avaliação do detector. Cada imagem contém entre um e três objetos pertencentes a uma das nove classes:

CLASSES = [
    'Triangle', 'Square', 'Pentagon', 'Hexagon',
    'Heptagon', 'Circle', 'Ellipse', 'Star', 'Cross'
]
  1. Geração das formas: As funções poligono_regular, poligono_estrela e poligono_cruz constroem as coordenadas dos objetos. A função desenha_objeto desenha cada forma com posição, tamanho, orientação e cor aleatórios e calcula sua bounding box.

  2. Anotação no formato YOLO: A função gera_imagem_ruidosa gera entre um e três objetos por imagem e converte cada bounding box para o formato YOLO, representado pela classe e pelas coordenadas normalizadas do centro, largura e altura.

  3. Degradação da imagem: A função adiciona_ruido_sal_pimenta adiciona ruído impulsivo, simulando imperfeições de aquisição.

  4. Visualização das amostras: A Figura 9.30 apresenta exemplos do conjunto sintético com as bounding boxes sobrepostas pela função mm.showBoundBox().

CLASSES = [
    'Triangle', 'Square', 'Pentagon', 'Hexagon',
    'Heptagon', 'Circle', 'Ellipse', 'Star', 'Cross'
]
N_LADOS = {'Triangle': 3, 'Square': 4, 'Pentagon': 5, 'Hexagon': 6, 'Heptagon': 7}

def poligono_regular(cx, cy, r, n_lados, rot_graus):
    ang0 = np.deg2rad(rot_graus - 90)
    angs = ang0 + 2 * np.pi * np.arange(n_lados) / n_lados
    return np.stack([cx + r * np.cos(angs), cy + r * np.sin(angs)], axis=1)

def poligono_estrela(cx, cy, r_externo, rot_graus, n_pontas=5):
    r_interno = r_externo * 0.45
    ang0 = np.deg2rad(rot_graus - 90)
    angs = ang0 + np.pi * np.arange(2 * n_pontas) / n_pontas
    raios = np.where(np.arange(2 * n_pontas) % 2 == 0, r_externo, r_interno)
    return np.stack([cx + raios * np.cos(angs), cy + raios * np.sin(angs)], axis=1)

def poligono_cruz(cx, cy, r, rot_graus, espessura_rel=0.35):
    w = r * espessura_rel
    base = np.array([
        (-w, -r), (w, -r), (w, -w), (r, -w), (r, w), (w, w),
        (w, r), (-w, r), (-w, w), (-r, w), (-r, -w), (-w, -w),
    ])
    theta = np.deg2rad(rot_graus)
    R = np.array([[np.cos(theta), -np.sin(theta)], [np.sin(theta), np.cos(theta)]])
    return base @ R.T + np.array([cx, cy])

def desenha_objeto(img, classe_idx, cx, cy, tamanho, rotacao, cor):
    nome = CLASSES[classe_idx]
    if nome in N_LADOS:
        pts = poligono_regular(cx, cy, tamanho, N_LADOS[nome], rotacao)
        cv2.fillPoly(img, [pts.astype(np.int32)], cor)
        xs, ys = pts[:, 0], pts[:, 1]
    elif nome == 'Star':
        pts = poligono_estrela(cx, cy, tamanho, rotacao)
        cv2.fillPoly(img, [pts.astype(np.int32)], cor)
        xs, ys = pts[:, 0], pts[:, 1]
    elif nome == 'Cross':
        pts = poligono_cruz(cx, cy, tamanho, rotacao)
        cv2.fillPoly(img, [pts.astype(np.int32)], cor)
        xs, ys = pts[:, 0], pts[:, 1]
    elif nome == 'Circle':
        cv2.circle(img, (int(cx), int(cy)), int(tamanho), cor, -1)
        xs, ys = np.array([cx - tamanho, cx + tamanho]), np.array([cy - tamanho, cy + tamanho])
    else:  # Ellipse
        eixo = (int(tamanho), int(tamanho * 0.6))
        cv2.ellipse(img, (int(cx), int(cy)), eixo, rotacao, 0, 360, cor, -1)
        ang = np.deg2rad(rotacao)
        dx = np.hypot(eixo[0] * np.cos(ang), eixo[1] * np.sin(ang))
        dy = np.hypot(eixo[0] * np.sin(ang), eixo[1] * np.cos(ang))
        xs, ys = np.array([cx - dx, cx + dx]), np.array([cy - dy, cy + dy])
    return xs.min(), ys.min(), xs.max(), ys.max()

def adiciona_ruido_sal_pimenta(img, quantidade=0.05):
    img_ruidosa = img.copy()
    h, w, c = img_ruidosa.shape
    num_ruido = int(quantidade * h * w)
    
    # Sal (255, 255, 255)
    coords_sal = [np.random.randint(0, i - 1, num_ruido) for i in (h, w)]
    img_ruidosa[coords_sal[0], coords_sal[1]] = [255, 255, 255]
    
    # Pimenta (0, 0, 0)
    coords_pimenta = [np.random.randint(0, i - 1, num_ruido) for i in (h, w)]
    img_ruidosa[coords_pimenta[0], coords_pimenta[1]] = [0, 0, 0]
    
    return img_ruidosa

def gera_imagem_ruidosa(tam_img=160, n_objetos=(1, 3), taxa_ruido=0.01, rng=None):
    rng = rng or random.Random()
    img_limpa = np.full((tam_img, tam_img, 3), 255, dtype=np.uint8)
    anotacoes = []
    
    for _ in range(rng.randint(*n_objetos)):
        classe_idx = rng.randrange(len(CLASSES))
        tamanho = rng.randint(tam_img // 10, tam_img // 5)
        cx = rng.randint(tamanho + 2, tam_img - tamanho - 2)
        cy = rng.randint(tamanho + 2, tam_img - tamanho - 2)
        rotacao = rng.uniform(0, 360)
        cor = tuple(rng.sample(range(30, 226), 3))
        
        x0, y0, x1, y1 = desenha_objeto(img_limpa, classe_idx, cx, cy, tamanho, rotacao, cor)
        x0, y0 = max(x0, 0), max(y0, 0)
        x1, y1 = min(x1, tam_img), min(y1, tam_img)
        
        # Padrão YOLO: (classe, x_centro, y_centro, largura, altura) normalizados
        xc, yc = (x0 + x1) / 2 / tam_img, (y0 + y1) / 2 / tam_img
        w, h = (x1 - x0) / tam_img, (y1 - y0) / tam_img
        anotacoes.append((classe_idx, xc, yc, w, h)) 
        
    img_ruidosa = adiciona_ruido_sal_pimenta(img_limpa, quantidade=taxa_ruido)
    return img_ruidosa, anotacoes
# Geração de 5 amostras para exibição inicial no topo do projeto
n_amostras_iniciais = 5
rng_demo = random.Random(42)

imgs_demo = []
titulos_demo = []

for idx in range(n_amostras_iniciais):
    img_ruid, anotacoes = gera_imagem_ruidosa(rng=rng_demo)
    
    # Gravação temporária da anotação para leitura nativa pelo mm.showBoundBox
    filename_temp = f"temp_label_{idx}.txt"
    with open(filename_temp, "w") as f:
        for c, xc, yc, w, h in anotacoes:
            f.write(f"{c} {xc:.4f} {yc:.4f} {w:.4f} {h:.4f}\n")
            
    img_anotada = mm.showBoundBox(img_ruid, filename=filename_temp, fmt="yolo", show=False)
    imgs_demo.append(img_anotada)
    titulos_demo.append(f"Amostra {idx+1}")

# Exibição do painel de 5 amostras
mm.show(
    imgs_demo,
    titles=titulos_demo,
    cols=n_amostras_iniciais,
    figsize=(14, 3)
)
Figura 9.30: Amostras iniciais do dataset sintético ruidoso de objetos geométricos com as caixas delimitadoras do formato YOLO sobrepostas.
9.5.2.2.2 Bloco 2: Organização do Dataset e Criação do Arquivo data.yaml

Este bloco organiza o conjunto de dados no formato esperado pela biblioteca Ultralytics YOLO. As imagens e as anotações são distribuídas em diretórios separados para treinamento e validação, enquanto o arquivo data.yaml reúne as informações necessárias para o treinamento do detector.

shapes_dataset/
├── data.yaml
├── images/
│   ├── train/
│   └── val/
└── labels/
    ├── train/
    └── val/
  1. Geração do conjunto de dados: O código cria 90 imagens para treinamento e 20 para validação. Para cada imagem, grava um arquivo .txt contendo uma linha por objeto, no formato YOLO (classe, x_c, y_c, largura, altura), com todas as coordenadas normalizadas.

  2. Organização dos arquivos: As imagens são armazenadas em images/train e images/val, enquanto as anotações correspondentes são gravadas em labels/train e labels/val, preservando o mesmo nome de arquivo.

  3. Criação do arquivo data.yaml: O código gera automaticamente o arquivo de configuração contendo o caminho do dataset, os diretórios de treinamento e validação e o mapeamento entre os índices numéricos e os nomes das nove classes.

base_dir = "shapes_dataset"
rng_global = random.Random(42)

for split, n_imgs in [("train", 90), ("val", 20)]:
    os.makedirs(f"{base_dir}/images/{split}", exist_ok=True)
    os.makedirs(f"{base_dir}/labels/{split}", exist_ok=True)
    
    for i in range(n_imgs):
        img_ruid, anotacoes = gera_imagem_ruidosa(rng=rng_global)
        cv2.imwrite(f"{base_dir}/images/{split}/{i:04d}.jpg", img_ruid)
        
        with open(f"{base_dir}/labels/{split}/{i:04d}.txt", "w") as f:
            for c, xc, yc, w, h in anotacoes:
                f.write(f"{c} {xc:.4f} {yc:.4f} {w:.4f} {h:.4f}\n")

with open(f"{base_dir}/data.yaml", "w") as f:
    f.write(
        f"path: {os.path.abspath(base_dir)}\n"
        "train: images/train\nval: images/val\nnames:\n"
    )
    for i, nome in enumerate(CLASSES):
        f.write(f"  {i}: {nome}\n")

print("Dataset ruidoso gerado com sucesso: 90 imagens de treino e 20 de validação.")
Dataset ruidoso gerado com sucesso: 90 imagens de treino e 20 de validação.
9.5.2.2.3 Bloco 3: Pré-processamento com Filtro de Mediana

Este bloco aplica um pré-processamento para reduzir o efeito do ruído do tipo sal e pimenta introduzido na geração do dataset. O Filtro de Mediana (cv2.medianBlur) remove esse tipo de degradação preservando melhor as bordas dos objetos do que filtros de suavização convencionais.

  1. Filtragem da imagem: O código aplica um filtro de mediana com janela \(3 \times 3\) à imagem ruidosa, reduzindo os pixels impulsivos sem alterar as anotações do conjunto de dados.

  2. Visualização comparativa: A Figura 9.31 compara a imagem original e a imagem filtrada, mantendo as bounding boxes sobrepostas por meio da função mm.showBoundBox().

# 1. Carregamento da primeira amostra ruidosa do dataset
caminho_img = f"{base_dir}/images/train/0000.jpg"
caminho_label = f"{base_dir}/labels/train/0000.txt"

img_ruidosa = mm.read(caminho_img)

# 2. Pré-processamento com Filtro de Mediana (janela 3x3)
img_filtrada = cv2.medianBlur(img_ruidosa, ksize=3)

# 3. Sobreposição das bounding boxes com mm.showBoundBox
img_ruid_anotada = mm.showBoundBox(img_ruidosa, filename=caminho_label, fmt="yolo", show=False)
img_filt_anotada = mm.showBoundBox(img_filtrada, filename=caminho_label, fmt="yolo", show=False)

# 4. Exibição comparativa com mm.show
mm.show(
    [img_ruid_anotada, img_filt_anotada],
    titles=[
        "1. Imagem Ruidosa Original (Sal e Pimenta)",
        "2. Pré-processada (Filtro de Mediana 3x3)"
    ],
    cols=2,
    figsize=(9, 4)
)
Figura 9.31: Comparação entre a imagem original com ruído do tipo sal e pimenta e a imagem após a aplicação do Filtro de Mediana (3x3). As bounding boxes no formato YOLO permanecem inalteradas.
9.5.2.2.4 Bloco 4: Pré-processamento do Dataset e Ajuste Fino do YOLOv8

Este bloco aplica o Filtro de Mediana ao conjunto de imagens e realiza o ajuste fino (fine-tuning) do detector YOLOv8n pré-treinado no conjunto COCO. A filtragem reduz o efeito do ruído impulsivo introduzido na geração das imagens, enquanto o treinamento adapta os parâmetros da rede ao novo domínio de formas geométricas.

  1. Filtragem em lote: O código percorre os diretórios train e val e aplica cv2.medianBlur com janela \(3 \times 3\) em todas as imagens, mantendo as anotações YOLO originais.

  2. Ajuste fino do detector: A rede YOLO("yolov8n.pt"), inicialmente treinada no COCO, é adaptada ao conjunto geométrico por meio da função .train(). O treinamento utiliza imagens com resolução \(320 \times 320\) pixels durante \(30\) épocas.

  3. Avaliação do modelo: A função .val() calcula as métricas de detecção no conjunto de validação, incluindo precisão (precision), revocação (recall) e mAP@50 (mean Average Precision com limiar de IoU igual a \(0,5\)).

import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

base_dir = "shapes_dataset"
base_dir_filt = "shapes_dataset_filtrado"

# 1. Cria uma CÓPIA filtrada do dataset em uma pasta separada
#    (o dataset original em base_dir permanece ruidoso, intacto)
for split in ["train", "val"]:
    pasta_imgs_orig = f"{base_dir}/images/{split}"
    pasta_labels_orig = f"{base_dir}/labels/{split}"
    pasta_imgs_filt = f"{base_dir_filt}/images/{split}"
    pasta_labels_filt = f"{base_dir_filt}/labels/{split}"

    os.makedirs(pasta_imgs_filt, exist_ok=True)
    os.makedirs(pasta_labels_filt, exist_ok=True)

    for nome_arq in os.listdir(pasta_imgs_orig):
        if nome_arq.endswith(".jpg"):
            img_ruidosa = cv2.imread(f"{pasta_imgs_orig}/{nome_arq}")
            img_filtrada = cv2.medianBlur(img_ruidosa, ksize=3)
            # grava a versão filtrada na pasta NOVA, não sobrescreve a original
            cv2.imwrite(f"{pasta_imgs_filt}/{nome_arq}", img_filtrada)

    # copia os labels (não mudam com o filtro)
    for nome_arq in os.listdir(pasta_labels_orig):
        shutil.copy(f"{pasta_labels_orig}/{nome_arq}", f"{pasta_labels_filt}/{nome_arq}")

# 2. data.yaml apontando para o dataset FILTRADO
with open(f"{base_dir_filt}/data.yaml", "w") as f:
    f.write(
        f"path: {os.path.abspath(base_dir_filt)}\n"
        "train: images/train\nval: images/val\nnames:\n"
    )
    for i, nome in enumerate(CLASSES):
        f.write(f"  {i}: {nome}\n")

print("Pré-processamento concluído: dataset filtrado salvo em pasta separada.\n")

# Baixar modelo YOLOv8 pré-treinado (yolov8n.pt) se ainda não estiver presente
with contextlib.redirect_stdout(io.StringIO()), \
    contextlib.redirect_stderr(io.StringIO()):
    modelo_yolo = YOLO("yolov8n.pt")
print("Modelo YOLOv8 carregado.")
Pré-processamento concluído: dataset filtrado salvo em pasta separada.

Modelo YOLOv8 carregado.
# 3. Callback customizado para imprimir apenas a época em execução
def on_train_epoch_start(trainer):
    epoch_atual = trainer.epoch + 1
    total_epochs = trainer.epochs
    # Escreve diretamente no stdout original (burlando o silenciador)
    sys.__stdout__.write(f"🔄 Processando Época {epoch_atual}/{total_epochs}...\n")
    sys.__stdout__.flush()

# Adiciona o callback ao modelo
modelo_yolo.add_callback("on_train_epoch_start", on_train_epoch_start)

# 4. Gerenciador de contexto para silenciar o lixo do Ultralytics (C/C++ e Python)
@contextlib.contextmanager
def silenciar_logs():
    logger = logging.getLogger("ultralytics")
    disabled_state = logger.disabled
    logger.disabled = True
    
    with open(os.devnull, "w") as fnull:
        old_stdout_fd = os.dup(1)
        old_stderr_fd = os.dup(2)
        try:
            os.dup2(fnull.fileno(), 1)
            os.dup2(fnull.fileno(), 2)
            with contextlib.redirect_stdout(fnull), contextlib.redirect_stderr(fnull):
                yield
        finally:
            os.dup2(old_stdout_fd, 1)
            os.dup2(old_stderr_fd, 2)
            os.close(old_stdout_fd)
            os.close(old_stderr_fd)
            logger.disabled = disabled_state

# Execução do Treinamento
print("--- Iniciando Treinamento YOLOv8 ---")
with silenciar_logs():
    resultados_treino = modelo_yolo.train(
        data=f"{base_dir_filt}/data.yaml",   # <-- treina no dataset filtrado
        epochs=30,
        imgsz=320,
        batch=16,
        device=device,
        verbose=False,
        plots=False
    )
    metricas = modelo_yolo.val(verbose=False)

# 5. Métricas Finais
precision = metricas.results_dict["metrics/precision(B)"]
recall = metricas.results_dict["metrics/recall(B)"]
map50 = metricas.results_dict["metrics/mAP50(B)"]

print("\n--- Desempenho Otimizado do Modelo YOLOv8 ---")
print(f"Precisão: {precision*100:.2f}%")
print(f"Revocação (Recall): {recall*100:.2f}%")
print(f"mAP a 50% (IoU 0.50): {map50*100:.2f}%")
--- Iniciando Treinamento YOLOv8 ---

--- Desempenho Otimizado do Modelo YOLOv8 ---
Precisão: 92.70%
Revocação (Recall): 80.64%
mAP a 50% (IoU 0.50): 87.21%
9.5.2.2.5 Bloco 5: Comparativo de Inferência: Imagem Ruidosa e Imagem Restaurada

Após o ajuste fino da YOLOv8 no conjunto restaurado, realiza-se uma comparação visual entre a detecção aplicada diretamente sobre uma imagem degradada pelo ruído sal e pimenta e a mesma imagem após o Filtro de Mediana.

O objetivo é observar como uma etapa simples de pré-processamento pode influenciar a qualidade das previsões de um detector já adaptado ao novo domínio.

  1. Inferência com a YOLO: A função modelo_yolo.predict() executa a detecção nas duas versões da imagem, utilizando um limiar de confiança de \(25\%\) (conf=0.25).

  2. Visualização das Predições: A função plot() gera as imagens anotadas com as caixas delimitadoras e os rótulos previstos pelo modelo. A Figura 9.32 apresenta a comparação entre os dois cenários.

# 1. Carregamento de uma amostra de teste original (sem o filtro salvo em lote)
caminho_teste = f"{base_dir}/images/val/0002.jpg"
img_ruidosa_teste = mm.read(caminho_teste)

# 2. Aplicação pontual do Filtro de Mediana (3x3) para comparação
img_filtrada_teste = cv2.medianBlur(img_ruidosa_teste, ksize=3)

# 3. Inferência com o modelo YOLOv8 treinado
pred_ruidosa = modelo_yolo.predict(img_ruidosa_teste, conf=0.25, verbose=False)[0]
pred_filtrada = modelo_yolo.predict(img_filtrada_teste, conf=0.25, verbose=False)[0]

# 4. Extração das matrizes anotadas pelo gerador do YOLO (conversão BGR -> RGB)
img_pred_ruid = cv2.cvtColor(pred_ruidosa.plot(), cv2.COLOR_BGR2RGB)
img_pred_filt = cv2.cvtColor(pred_filtrada.plot(), cv2.COLOR_BGR2RGB)

# 5. Exibição comparativa padronizada via mm.show
mm.show(
    [img_pred_ruid, img_pred_filt],
    titles=[
        f"Inferência na Imagem Ruidosa ({len(pred_ruidosa.boxes)} objetos)",
        f"Inferência na Imagem Filtrada ({len(pred_filtrada.boxes)} objetos)"
    ],
    cols=2,
    figsize=(10, 4)
)
Figura 9.32: Comparação da inferência da YOLOv8 ajustada por transferência de aprendizado em uma imagem com ruído sal e pimenta e na versão restaurada pelo Filtro de Mediana. As caixas delimitadoras e classes previstas são exibidas sobre cada imagem.
# Limpeza explícita dos dados baixados e liberação de memória
if FLAG_LIMPAR_DADOS:
    if os.path.exists(base_dir):
        shutil.rmtree(base_dir)
        print('🧹 Diretório de dados temporários {} removido com sucesso.'.format(base_dir))

    if os.path.exists(base_dir_filt):
        shutil.rmtree(base_dir_filt)
        print('🧹 Diretório de dados temporários {} removido com sucesso.'.format(base_dir_filt))

    if torch.cuda.is_available():
        torch.cuda.empty_cache()
Nota🧠 Um domínio muito mais distante que o dos dígitos

No experimento de transferência de aprendizado entre dígitos manuscritos (domínios A e B), a tarefa de origem e a de destino compartilhavam estatísticas visuais muito próximas: ambas eram traços em tons de cinza sobre fundo uniforme. Aqui, a distância entre domínios é bem maior — o YOLO foi pré-treinado em fotografias naturais coloridas da COCO (pessoas, animais, veículos, objetos do cotidiano), e a tarefa de destino consiste em formas geométricas sintéticas, de cor sólida e contorno bem definido, sem textura, iluminação ou fundo complexo.

Ainda assim, a transferência de aprendizado é vantajosa: as camadas iniciais de um detector treinado na COCO aprendem filtros genéricos — detectores de bordas, cantos e regiões de contraste — que continuam úteis para delimitar o contorno de um triângulo ou de uma estrela, mesmo que o conteúdo visual final seja bastante distinto. É por isso que permitir o ajuste fino (fine-tuning) de todas as camadas, combinado com um pré-processamento consistente entre treino e inferência para atenuar o ruído sal e pimenta, viabiliza taxas de acurácia elevadas na detecção de objetos do novo domínio.

9.5.2.2.6 Usando um Conjunto de Dados Real

O pipeline acima foi construído inteiramente em torno do formato de anotação YOLO (classe, \(x_{centro}\), \(y_{centro}\), largura, altura, normalizados pela largura e altura da imagem) exatamente para que ele possa ser reaproveitado sem alterações caso o leitor tenha acesso a um conjunto de imagens reais anotado da mesma forma — por exemplo, um conjunto de imagens de objetos geométricos fotografados ou renderizados, cada um com um arquivo .txt correspondente no mesmo formato usado aqui. Para isso, bastaria:

  1. Organizar as imagens reais em shapes_dataset/images/train e shapes_dataset/images/val, e os arquivos .txt de anotação correspondentes nas pastas labels/train e labels/val (um arquivo de anotação por imagem, mesmo nome-base, extensão .txt);
  2. Ajustar o arquivo data.yaml caso o número ou os nomes das classes sejam diferentes;
  3. Executar as mesmas células de treinamento, ajuste fino e visualização já apresentadas, sem qualquer outra modificação de código.

Essa separação entre geração/organização dos dados e treinamento do modelo é, na prática, o motivo pelo qual formatos de anotação padronizados (como o do YOLO) são tão amplamente adotados: eles permitem trocar o conjunto de dados de entrada — sintético por real, um domínio por outro — mantendo inalterado todo o restante do pipeline de transferência de aprendizado.

9.5.3 Segmentação de Objetos

O mesmo princípio de transferência de aprendizado também fundamenta arquiteturas de segmentação de imagens, nas quais um extrator de características pré-treinado fornece representações visuais gerais, enquanto uma cabeça especializada realiza a classificação densa, pixel a pixel.

As próximas seções apresentam a DeepLabV3 como exemplo de segmentador pré-treinado usado diretamente para inferência e, em seguida, a arquitetura U-Net, treinada do zero e comparada com uma linha de base morfológica clássica.

9.5.3.1 DeepLabV3: Segmentador Pré-treinado

Na segmentação semântica, o objetivo não se limita à localização dos objetos por meio de caixas delimitadoras (bounding boxes). A rede atribui uma classe a cada pixel da imagem, produzindo um mapa de rótulos com a mesma resolução da entrada. Arquiteturas como a DeepLabV3, com backbone ResNet-50, utilizam um extrator de características pré-treinado e uma cabeça especializada para realizar essa classificação densa.

  1. Carregamento e inferência: O modelo deeplabv3_resnet50(weights="DEFAULT") carrega pesos pré-treinados no conjunto Pascal VOC, que define \(21\) classes de segmentação. O código converte a imagem em tensor, adiciona a dimensão de batch (unsqueeze(0)) e executa a inferência.

  2. Mapa de classes: A saída do modelo possui dimensão \((1, 21, H, W)\), contendo um valor para cada classe em cada pixel. A operação .argmax(dim=1) seleciona a classe de maior resposta em cada posição, gerando uma matriz bidimensional de rótulos com dimensões \((H, W)\).

  3. Visualização: O código converte o mapa de rótulos para o formato esperado por mm.show(), que exibe o resultado da segmentação na Figura 9.33.

# 1. Carregamento da imagem (retorna numpy.ndarray)
url_imagem = "https://raw.githubusercontent.com/pytorch/hub/master/images/dog.jpg"
url_imagem = "http://images.cocodataset.org/val2017/000000039769.jpg"
img = mm.read(url_imagem)

# 2. Carregamento do modelo DeepLabV3 pré-treinado no modo de avaliação
modelo_segmentacao = deeplabv3_resnet50(weights="DEFAULT").eval()

# 3. Execução da inferência sem cálculo de gradientes
with torch.no_grad():
    tensor_entrada = to_tensor(img).unsqueeze(0)  # Formato (1, C, H, W)
    saida = modelo_segmentacao(tensor_entrada)["out"]
    
    # Seleção da classe com maior probabilidade por pixel (argmax no eixo dos canais)
    mapa_classes = saida.argmax(dim=1).squeeze(0).byte().cpu().numpy()

# 4. Exibição do mapa de segmentação semântica
mm.show(
    [img,mapa_classes],
    title=["Imagem original","Segmentação Semântica (DeepLabV3)"]
)
Figura 9.33: Mapa de segmentação semântica gerado pelo modelo DeepLabV3 pré-treinado: classificação pixel a pixel representada em matriz 2D exibida.

9.5.3.2 Segmentação Semântica com Arquitetura U-Net

A subseção anterior apresentou um modelo pré-treinado (DeepLabV3) produzindo diretamente um rótulo de classe por pixel. Esta seção completa a sequência de projetos práticos — classificação e detecção — abordando a segmentação semântica implementada e treinada do zero com a U-Net, a arquitetura de referência introduzida por Ronneberger; Fischer; Brox (2015).

Na classificação o mapa de características final era achatado (flatten) em um vetor, descartando a informação espacial em favor de um único rótulo por imagem. A U-Net, por sua vez, produz uma saída com a mesma resolução espacial da entrada: um mapa bidimensional no qual cada pixel recebe sua própria classificação. Essa exigência — preservar detalhe espacial de alta resolução ao mesmo tempo em que se constrói contexto semântico em camadas profundas — motiva a arquitetura de codificador-decodificador com conexões de atalho (skip connections).

O cenário utilizado simula a segmentação de nódulos em exames médicos sintéticos: imagens em escala de cinza contêm uma região circular (“nódulo”) sobreposta a um fundo, ambos contaminados por ruído gaussiano e com médias de intensidade muito próximas — um desafio intencional de baixo contraste, ideal para demonstrar o ganho do aprendizado espacial em relação à limiarização pontual.

9.5.3.2.1 Bloco 1: Gerador do Conjunto Sintético de Nódulos e Exibição Inicial

O gerador a seguir produz pares (imagem, máscara): a imagem contém uma região circular de intensidade ligeiramente superior à do fundo, ambas afetadas pelo mesmo desvio-padrão de ruído gaussiano. A máscara binária delimita exatamente a região do nódulo e serve como verdade de referência (ground truth).

  1. Construção do Nódulo: A função gera_imagem_com_nodulo sobrepõe o nódulo ao fundo sobre uma matriz \(64 \times 64\) e aplica ruído gaussiano.
  2. Visualização com mm.show: A Figura 9.34 ilustra as duas primeiras amostras e suas respectivas máscaras.
TAM_IMG = 64


def gera_imagem_com_nodulo(
    tam=TAM_IMG,
    raio_min=7,
    raio_max=15,
    media_fundo=95,
    media_nodulo=118,
    sigma_ruido=26,
    rng=None,
):
    rng = rng or np.random.default_rng()
    fundo = rng.normal(media_fundo, sigma_ruido, (tam, tam))
    nodulo = rng.normal(media_nodulo, sigma_ruido, (tam, tam))
    mascara = np.zeros((tam, tam), dtype=np.uint8)

    raio = int(rng.integers(raio_min, raio_max))
    cx = int(rng.integers(raio + 4, tam - raio - 4))
    cy = int(rng.integers(raio + 4, tam - raio - 4))

    cv2.circle(mascara, (cx, cy), raio, 255, -1)
    imagem = np.clip(np.where(mascara > 0, nodulo, fundo), 0, 255).astype(
        np.uint8
    )
    return imagem, mascara


# Geração dos conjuntos de treinamento e validação
rng_dados = np.random.default_rng(42)
N_TREINO, N_VAL = 160, 40

imgs_treino, masks_treino = zip(
    *[gera_imagem_com_nodulo(rng=rng_dados) for _ in range(N_TREINO)]
)
imgs_val, masks_val = zip(
    *[gera_imagem_com_nodulo(rng=rng_dados) for _ in range(N_VAL)]
)

print(
    f"Conjunto de treino: {N_TREINO} imagens | Conjunto de validação: {N_VAL} imagens\n"
)

# Exibição de amostras iniciais 
mm.show(
    [imgs_treino[0], masks_treino[0], imgs_treino[1], masks_treino[1]],
    titles=["Imagem 1", "Máscara 1", "Imagem 2", "Máscara 2"],
    cols=4,
    figsize=(11, 3),
)
Conjunto de treino: 160 imagens | Conjunto de validação: 40 imagens
Figura 9.34: Amostras do conjunto de dados sintético de nódulos: imagem em escala de cinza sob ruído e respectiva máscara binária de referência exibidass.
9.5.3.2.2 Bloco 2: Linha de Base Clássica (Filtragem, Otsu e Morfologia)

Antes de empregar a U-Net, avalia-se o desempenho de um pipeline morfológico clássico construído com a biblioteca morph: um suavizador gaussiano (mm.blur), uma limiarização de Otsu (mm.threshold) e uma abertura morfológica (mm.open) para eliminação de ruídos isolados.

  1. Métrica de Interseção sobre União (IoU): A função iou_mascaras calcula o grau de sobreposição pixel a pixel entre a predição e a máscara real.
  2. Execução e Comparativo: A Figura 9.35 exibe o resultado da segmentação clássica em uma imagem de teste, demonstrando as limitações do limiar global sob baixo contraste.
def iou_mascaras(predita, referencia):
    p, r = predita > 0, referencia > 0
    intersecao = np.logical_and(p, r).sum()
    uniao = np.logical_or(p, r).sum()
    return intersecao / uniao if uniao else 1.0


def segmenta_classico(imagem, elemento_estrutural):
    suavizada = mm.blur(imagem, 7)
    binaria = mm.threshold(suavizada)
    return mm.open(binaria, elemento_estrutural)


elemento_estrutural = mm.sedisk(5)
ious_classico = [
    iou_mascaras(segmenta_classico(img, elemento_estrutural), mask)
    for img, mask in zip(imgs_val, masks_val)
]
iou_classico_medio = float(np.mean(ious_classico))
print(
    f"IoU médio (linha de base clássica) na validação: {iou_classico_medio:.4f}\n"
)

predicao_classica_exemplo = segmenta_classico(imgs_val[0], elemento_estrutural)

mm.show(
    [imgs_val[0], masks_val[0], predicao_classica_exemplo],
    titles=["Imagem", "Máscara de Referência", "Predição Clássica"],
    cols=3,
    figsize=(9, 3.2),
)
IoU médio (linha de base clássica) na validação: 0.7516
Figura 9.35: Linha de base clássica de segmentação: suavização, limiarização de Otsu e abertura morfológica exibidas.
9.5.3.2.3 Bloco 3: Construção da Arquitetura U-Net e Funções de Perda

A U-Net é uma arquitetura em formato de “U” (daí o nome), pensada especificamente para segmentação de imagens. Ela é formada por dois caminhos que trabalham em conjunto:

  • 🔽 Codificador (encoder): desce pela imagem, reduzindo a resolução espacial a cada etapa enquanto extrai características cada vez mais abstratas (bordas → texturas → formas → contexto).
  • 🔼 Decodificador (decoder): sobe de volta, reconstruindo a resolução original através de convoluções transpostas (upsampling), até gerar uma máscara do mesmo tamanho da imagem de entrada.

O elemento que torna a U-Net especial são as conexões de atalho (skip connections): elas levam os mapas de características do codificador diretamente para a etapa correspondente do decodificador, na mesma resolução. Isso evita que detalhes finos — como contornos e bordas — se percam durante a compressão espacial.

Fluxo geral da arquitetura:

Entrada
  │
  ▼
Codificador (Conv → Conv → Pool) × 3
  │
  ├──── conexões de atalho reinjetam mapas de alta resolução  ────┐
  ▼                                                               │
Base (bottleneck)                                                 │
  │                                                               │
  ▼                                                               │
Decodificador (Upsample → Concat → Conv → Conv) × 3  ◄────────────┘
  │
  ▼
Saída 1×1 (logits)

Componentes principais:

  1. Bloco Convolucional Base (BlocoConv) A unidade fundamental repetida em toda a rede. Aplica duas convoluções \(3 \times 3\) em sequência, cada uma seguida de ativação ReLU, com padding que preserva as dimensões espaciais da entrada. É esse bloco que aparece tanto no codificador quanto no decodificador.

  2. Convolução Transposta (nn.ConvTranspose2d) É a operação responsável pelo upsampling no decodificador: ao invés de reduzir a resolução espacial (como o MaxPool2d faz no codificador), ela a aumenta, aprendendo os pesos necessários para “desfazer” a compressão e recuperar gradualmente o tamanho original da imagem.

  3. Perda Combinada (BCE + Dice) A função perda_segmentacao soma duas métricas complementares:

    • Entropia Cruzada Binária (BCE): avalia o acerto pixel a pixel.
    • Coeficiente de Dice: avalia a sobreposição global entre a máscara prevista e a real.

    Juntas, elas equilibram precisão local com fidelidade da forma segmentada como um todo.

class BlocoConv(nn.Module):

    def __init__(self, canais_entrada, canais_saida):
        super().__init__()
        self.rede = nn.Sequential(
            nn.Conv2d(canais_entrada, canais_saida, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.Conv2d(canais_saida, canais_saida, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
        )

    def forward(self, x):
        return self.rede(x)


class UNetCompacta(nn.Module):

    def __init__(self, canais_entrada=1, base=8):
        super().__init__()
        self.enc1 = BlocoConv(canais_entrada, base)
        self.enc2 = BlocoConv(base, base * 2)
        self.enc3 = BlocoConv(base * 2, base * 4)
        self.pool = nn.MaxPool2d(2)

        self.fundo = BlocoConv(base * 4, base * 8)

        self.up3 = nn.ConvTranspose2d(
            base * 8, base * 4, kernel_size=2, stride=2
        )
        self.dec3 = BlocoConv(base * 8, base * 4)
        self.up2 = nn.ConvTranspose2d(
            base * 4, base * 2, kernel_size=2, stride=2
        )
        self.dec2 = BlocoConv(base * 4, base * 2)
        self.up1 = nn.ConvTranspose2d(
            base * 2, base, kernel_size=2, stride=2
        )
        self.dec1 = BlocoConv(base * 2, base)

        self.saida = nn.Conv2d(base, 1, kernel_size=1)

    def forward(self, x):
        e1 = self.enc1(x)
        e2 = self.enc2(self.pool(e1))
        e3 = self.enc3(self.pool(e2))
        f = self.fundo(self.pool(e3))

        d3 = self.dec3(torch.cat([self.up3(f), e3], dim=1))
        d2 = self.dec2(torch.cat([self.up2(d3), e2], dim=1))
        d1 = self.dec1(torch.cat([self.up1(d2), e1], dim=1))
        return self.saida(d1)


def para_tensores(imagens, mascaras):
    X = (
        torch.tensor(np.stack(imagens), dtype=torch.float32).unsqueeze(1)
        / 255.0
    )
    Y = (
        torch.tensor(np.stack(mascaras), dtype=torch.float32).unsqueeze(1)
        / 255.0
    )
    return X, Y


def perda_dice(logits, alvo, eps=1e-6):
    probs = torch.sigmoid(logits)
    intersecao = (probs * alvo).sum(dim=(1, 2, 3))
    uniao = probs.sum(dim=(1, 2, 3)) + alvo.sum(dim=(1, 2, 3))
    dice = (2 * intersecao + eps) / (uniao + eps)
    return 1 - dice.mean()


def perda_segmentacao(logits, alvo):
    return nn.functional.binary_cross_entropy_with_logits(
        logits, alvo
    ) + perda_dice(logits, alvo)
9.5.3.2.4 Bloco 4: Treinamento da U-Net e Avaliação de Desempenho

O treinamento é executado por \(35\) épocas utilizando o otimizador Adam. A cada época, monitora-se a Perda de Treinamento e o índice IoU médio no conjunto de validação.

  1. Laço de Treinamento: Atualizam-se os parâmetros com mini-batches de \(16\) amostras.
  2. Evolução Gráfica: A Figura 9.36 exibe o gráfico com o progresso da perda e do IoU.
modelo_unet = UNetCompacta()
print(
    f"Parâmetros treináveis da U-Net: {sum(p.numel() for p in modelo_unet.parameters())}"
)

X_treino_unet, Y_treino_unet = para_tensores(imgs_treino, masks_treino)
X_val_unet, Y_val_unet = para_tensores(imgs_val, masks_val)

otimizador_unet = optim.Adam(modelo_unet.parameters(), lr=1e-3)
n = X_treino_unet.size(0)
tam_lote = 16
epocas_unet = 35
historico_perda_unet, historico_iou_unet = [], []

for epoca in range(epocas_unet):
    if epoca % 5 == 0:  # Imprime a cada 5 épocas
        print(epoca + 1, "/", epocas_unet)
    modelo_unet.train()
    perm = torch.randperm(n)
    perda_epoca = 0.0

    for i in range(0, n, tam_lote):
        idx = perm[i : i + tam_lote]
        otimizador_unet.zero_grad()
        logits = modelo_unet(X_treino_unet[idx])
        perda = perda_segmentacao(logits, Y_treino_unet[idx])
        perda.backward()
        otimizador_unet.step()
        perda_epoca += perda.item() * len(idx)

    modelo_unet.eval()
    with torch.no_grad():
        predicao_val = (torch.sigmoid(modelo_unet(X_val_unet)) > 0.5).float()
        intersecao = (predicao_val * Y_val_unet).sum(dim=(1, 2, 3))
        uniao = ((predicao_val + Y_val_unet) > 0).float().sum(dim=(1, 2, 3))
        iou_epoca = (intersecao / uniao.clamp(min=1e-6)).mean().item()

    historico_perda_unet.append(perda_epoca / n)
    historico_iou_unet.append(iou_epoca)

iou_unet_final = historico_iou_unet[-1]
print(f"IoU médio final da U-Net na validação: {iou_unet_final:.4f}")

# Gráfico da evolução do treinamento
r = mm.showTrainCurves(historico_perda_unet, historico_iou_unet,
        titulo="Treinamento da U-Net — Segmentação de Nódulos Sintéticos",
        subtitulo="Perda no conjunto de treino e IoU médio no conjunto de validação \
            ao longo de 35 épocas")
Parâmetros treináveis da U-Net: 120681
1 / 35
6 / 35
11 / 35
16 / 35
21 / 35
26 / 35
31 / 35
IoU médio final da U-Net na validação: 0.8886
Figura 9.36: Evolução do treinamento da U-Net compacta: redução da perda e elevação do índice IoU médio no conjunto de validação ao longo de 35 épocas.
9.5.3.2.5 Bloco 5: Comparativo Quantitativo e Qualitativo (Clássico vs. U-Net)

A comparação entre a abordagem clássica e a U-Net explicita a superioridade do aprendizado de representações em cenários de baixo contraste.

  1. Painel de Métricas: O gráfico de barras na Figura 9.37 contrasta o IoU médio de ambos os métodos na validação.
  2. Visualização Qualitativa: A comparação visual em três amostras demonstra como as conexões de atalho recuperam o contorno do nódulo mesmo sob ruído acentuado.
# 1.  comparativo 
print(f"IoU médio (clássico Suavização + Otsu): {iou_classico_medio:.4f}")
print(f"IoU médio (U-Net): {iou_unet_final:.4f}")

# 2. Exibição qualitativa lado a lado em 3 amostras via mm.show
imgs_comparativas = []
titulos_comparativos = []

for i in range(3):
    with torch.no_grad():
        pred_unet = (
            (torch.sigmoid(modelo_unet(X_val_unet[i : i + 1])) > 0.5)
            .float()
            .squeeze()
            .numpy()
            * 255
        )

    pred_classico = segmenta_classico(imgs_val[i], elemento_estrutural)

    imgs_comparativas.extend(
        [imgs_val[i], masks_val[i], pred_classico, pred_unet.astype(np.uint8)]
    )

    t_prefix = f"Amostra {i+1}"
    titulos_comparativos.extend(
        [
            f"{t_prefix}: Imagem",
            f"{t_prefix}: Referência",
            f"{t_prefix}: Clássico",
            f"{t_prefix}: U-Net",
        ]
    )

mm.show(
    imgs_comparativas,
    titles=titulos_comparativos,
    cols=4,
    figsize=(11, 7.5),
)
IoU médio (clássico Suavização + Otsu): 0.7516
IoU médio (U-Net): 0.8886
Figura 9.37: Comparativo quantitativo (IoU médio) e qualitativo entre a abordagem clássica e a U-Net treinada em três amostras de validação.
Nota🧠 Por que a U-Net supera a limiarização fixa?

A limiarização de Otsu aplica um valor de corte global sobre a intensidade local. Quando a diferença de média entre o nódulo e o fundo é pequena diante do ruído gaussiano, essa regra comete erros sistemáticos nas bordas.

A U-Net contorna essa limitação ao combinar o contexto semântico amplo extraído pelo codificador com os detalhes espaciais finos preservados pelas conexões de atalho. Isso permite identificar a presença do nódulo e delimitar seus contornos com precisão, mesmo sob ruído intenso.

9.5.4 Engenharia de Dados para VC (Roboflow)

Uma U-Net pode ser treinada a partir de imagens anotadas em plataformas de engenharia de dados para Visão Computacional (VC), como o Roboflow. Essas plataformas permitem organizar conjuntos de dados, realizar anotações, aplicar etapas de pré-processamento e data augmentation, treinar modelos e exportar os dados em diferentes formatos. Nesta seção, entretanto, o exemplo retoma a detecção de objetos geométricos, utilizando conjuntos de dados já apresentados neste livro.

ImportanteDependência de conexão e chave de API

As células desta seção exigem conexão com a Internet e uma chave de API gratuita do Roboflow (app.roboflow.com). No Workspace do projeto, acesse ⚙ → Roboflow API e copie a Private API Key.

Crie, nesta pasta, o arquivo chave_roboflow.txt contendo somente a chave, sem aspas. Um modelo desse arquivo está disponível em chave_roboflow.txt.exemplo.

Adicione chave_roboflow.txt ao arquivo .gitignore, pois ele contém uma credencial de acesso que não deve ser versionada nem compartilhada.

9.5.4.1 Detecção de objetos utilizando Roboflow

O Roboflow permite realizar inferência sobre imagens locais, possibilitando avaliar o desempenho do modelo hospedado na identificação dos objetos de interesse.

Além da inferência, o dataset pode ser exportado no formato png-mask-semantic, no qual cada imagem é acompanhada por uma máscara de segmentação semântica. Nessa máscara, cada pixel representa a classe à qual pertence o objeto correspondente. Os pares imagem-máscara são utilizados como dados de treinamento para a UNetCompacta.

9.5.4.2 Conexão, download e verificação do dataset

O código estabelece conexão com o Workspace mctest e o projeto geometric-test00, versão 6, e realiza o download do dataset para dados/datasetRoboFlow. Em seguida, verifica o shape das imagens em cada split. A função de verificação é reutilizada posteriormente na seção.

from roboflow import Roboflow
from pathlib import Path
from PIL import Image
from collections import Counter

def contar_shapes(raiz, splits=("train", "valid", "test")):
    """Conta o shape (altura, largura, canais) das imagens por split."""
    for split in splits:
        pasta = raiz / split / "images"
        if not pasta.exists():
            print(f"{split}: pasta não encontrada")
            continue

        shapes = Counter()
        for arquivo in pasta.iterdir():
            if arquivo.is_file():
                with Image.open(arquivo) as img:
                    shapes[(img.height, img.width, len(img.getbands()))] += 1

        txt = ", ".join(f"{s}: {n}" for s, n in shapes.items())
        print(f"{split}: {txt}")


chave = Path("chave_roboflow.txt")

if not chave.exists():
    print("Chave do Roboflow não encontrada: chave_roboflow.txt")
else:
    with open(chave) as f:
        api_key = f.read().strip()

    # Projeto:
    # https://app.roboflow.com/mctest/geometric-test00/models
    # geometric-test00/6

    rf = Roboflow(api_key=api_key)
    projeto = rf.workspace("mctest").project("geometric-test00")
    versao = projeto.version(6)

    print(
        f"ID: {versao.version} | Nome: {versao.name} | "
        f"Imagens: {versao.images}"
    )

    raiz = Path("dados/datasetRoboFlow")
    versao.download("yolov8", location=str(raiz))

    print("Dataset:", raiz)
    contar_shapes(raiz)
loading Roboflow workspace...
loading Roboflow project...
ID: 6 | Nome: Geometric test00 | Imagens: 100
Dataset: dados/datasetRoboFlow
train: (640, 640, 3): 70
valid: (640, 640, 3): 20
test: (640, 640, 3): 10

9.5.4.3 Baixando o dataset de forma reprodutível (alternativa)

Como alternativa ao dataset obtido pelo Roboflow, pode-se utilizar um dataset disponibilizado em um repositório GitHub, também organizado nos mesmos splits e contendo as mesmas classes de objetos. Os conjuntos de dados, entretanto, não são idênticos: as imagens do GitHub possuem resolução de 608×608 pixels, enquanto as imagens exportadas pelo Roboflow possuem 640×640 pixels.

O código abaixo realiza o download do dataset do GitHub, caso ele ainda não esteja disponível localmente, e reutiliza contar_shapes para verificar as dimensões das imagens em cada split.

import os

if not os.path.exists("dados/dataset"):
    cmd = (
        "git clone --no-checkout --depth 1 --filter=blob:none "
        "https://github.com/fzampirolli/pdi-vc.git tmp_repo && "
        "cd tmp_repo && git sparse-checkout set all/cap09/dados/dataset "
        "&& git checkout && cd .. && mkdir -p dados && "
        "cp -r tmp_repo/all/cap09/dados/dataset dados/dataset && "
        "rm -rf tmp_repo"
    )
    !{cmd}

if not chave.exists():
    print("Chave do Roboflow não encontrada: chave_roboflow.txt")
else:
  versao_recente = projeto.versions()[-1]
  print(f"Versão mais recente: {versao_recente.version.split('/')[-1]}")

  contar_shapes(Path("dados/dataset"))
Versão mais recente: 1
train: (608, 608, 3): 70
valid: (608, 608, 3): 20
test: (608, 608, 3): 10

9.5.4.4 Comparando uma imagem de cada dataset

Os dois datasets possuem imagens com resoluções diferentes: 608×608 no GitHub e 640×640 no Roboflow. Para uma comparação visual direta, as imagens são redimensionadas para a mesma dimensão antes de serem exibidas lado a lado (Figura 9.38).

import cv2

if not chave.exists():
    print("Chave do Roboflow não encontrada: chave_roboflow.txt")
else:
    caminho1 = next((raiz / "train/images").iterdir())
    caminho2 = next((Path("dados/dataset") / "train/images").iterdir())

    img1 = mm.read(str(caminho1))
    img2 = mm.read(str(caminho2))

    # Redimensiona ambas para o mesmo tamanho (o menor entre as duas)
    largura = min(img1.shape[1], img2.shape[1])
    altura = min(img1.shape[0], img2.shape[0])
    img1_r = cv2.resize(img1, (largura, altura))
    img2_r = cv2.resize(img2, (largura, altura))

    mm.show(
        [img1_r, img2_r],
        title=[f"Roboflow {img1.shape}", f"GitHub {img2.shape}"],
    )
Figura 9.38: Uma imagem de cada dataset, redimensionadas para comparação

9.5.4.5 Inferência com o modelo treinado

O modelo treinado na versão 6 é utilizado para realizar a inferência sobre uma imagem de teste local. A predição considera os limiares de confiança e de sobreposição empregados pela supressão de não máximos (Non-Maximum Suppression, NMS), e o resultado é apresentado na imagem anotada da Figura 9.39.

# version.model está deprecated; usar version.models()

if not chave.exists():
    print("Chave do Roboflow não encontrada: chave_roboflow.txt")
else:
    modelo = versao.models()[0]

    img_path = "dados/dataset/test/images/00001.jpg"
    pred = modelo.predict(img_path, confidence=40, overlap=30)
    resp = pred.json()  # inclui as caixas detectadas em resp["predictions"]

    altura, largura, _ = mm.read(img_path).shape
    print("Dimensões da imagem de teste:", (altura, largura))

    pred.save("resultado.jpg")  # imagem anotada

    mm.show(
        mm.read("resultado.jpg"),
        title="Resultado da inferência com o modelo do Roboflow",
        figsize=(6, 6)
    )
Dimensões da imagem de teste: (608, 608)
Figura 9.39: Resultado da inferência com o modelo do Roboflow

9.5.4.6 Avaliando as predições com IoU e classe

O Roboflow retorna cada caixa com as coordenadas do centro (x, y) em pixels e a classe prevista, enquanto os rótulos locais (dados/dataset/test/labels/00001.txt) seguem o formato YOLO, com centro e dimensões normalizados no intervalo [0, 1]. Antes da comparação por meio de mm.IoU, as caixas devem ser convertidas para o mesmo formato, com as coordenadas do canto superior esquerdo e as dimensões expressas em pixels.

Uma predição só é considerada correta quando a classe prevista coincide com a classe da caixa real e sua Intersection over Union (IoU) é maior ou igual ao limiar definido, adotando-se, neste exemplo, 0,5 (50%) como valor padrão.

ImportanteO class_id do Roboflow não corresponde ao índice das labels locais

Na exportação, o Roboflow reordena as classes em ordem alfabética no data.yaml, independentemente da ordem utilizada no projeto original, mantida no data.yaml do GitHub. Assim, class_id = 0 corresponde a Circulo no retorno da API, enquanto o mesmo índice corresponde a Triangulo nas labels locais.

Por isso, a comparação deve ser feita pelo nome da classe (p["class"]), convertendo-o posteriormente para o índice correspondente na lista local. Os class_id não devem ser comparados diretamente.

# resp, largura e altura foram definidos na célula anterior

# Ordem das classes usada nas labels locais (dados/dataset/*/labels/*.txt)
CLASSES_LOCAIS = ["Triangulo", "Quadrado", "Pentagono", "Hexagono",
                   "Heptagono", "Circulo", "Elipse"]

def predicao_correta(pred: tuple, real: tuple, limiar: float = 0.5) -> bool:
    """True se mesma classe e mm.IoU(caixa_pred, caixa_real) >= limiar."""
    classe_pred, caixa_pred = pred
    classe_real, caixa_real = real
    return classe_pred == classe_real and mm.IoU(caixa_pred, caixa_real) >= limiar

def caixa_roboflow(p: dict) -> tuple:
    """Converte predição do Roboflow para (classe, (x, y, w, h))."""
    caixa = (p["x"] - p["width"] / 2, p["y"] - p["height"] / 2,
             p["width"], p["height"])
    # usa o nome da classe (não o class_id!) para casar com a ordem local
    classe = CLASSES_LOCAIS.index(p["class"])
    return (classe, caixa)

def carrega_labels_yolo(caminho_txt: str, largura: int, altura: int) -> list:
    """Lê rótulos YOLO (normalizados) e converte para (classe, (x, y, w, h))."""
    caixas = []
    with open(caminho_txt) as f:
        for linha in f:
            classe, xc, yc, w, h = map(float, linha.split())
            w_px, h_px = w * largura, h * altura
            x_px = xc * largura - w_px / 2
            y_px = yc * altura - h_px / 2
            caixas.append((int(classe), (x_px, y_px, w_px, h_px)))
    return caixas

if not chave.exists():
    print("Chave do Roboflow não encontrada: chave_roboflow.txt")
else:
    caixas_pred = [caixa_roboflow(p) for p in resp["predictions"]]
    caixas_real = carrega_labels_yolo(
        "dados/dataset/test/labels/00001.txt", largura, altura
    )

    acertos = sum(
        any(predicao_correta(cp, cr, limiar=0.5) for cr in caixas_real)
        for cp in caixas_pred
    )
    print(f"{acertos}/{len(caixas_pred)} predições corretas (classe + IoU >= 50%)")
21/21 predições corretas (classe + IoU >= 50%)

Para visualizar o resultado, cada predição é desenhada sobre a imagem: verde quando é um acerto (classe + IoU ≥ limiar) e vermelho quando é um erro, como mostra a Figura 9.40.

import cv2

VERDE, VERMELHO = (0, 255, 0), (255, 0, 0)

if not chave.exists():
    print("Chave do Roboflow não encontrada: chave_roboflow.txt")
else:
    img_acertos = mm.read(img_path).copy()

    if not chave.exists():
        print("Chave do Roboflow não encontrada: chave_roboflow.txt")
    else:
        for cp in caixas_pred:
            classe_pred, (x, y, w, h) = cp
            correta = any(predicao_correta(cp, cr, limiar=0.5) for cr in caixas_real)
            cor = VERDE if correta else VERMELHO

            p1, p2 = (int(x), int(y)), (int(x + w), int(y + h))
            cv2.rectangle(img_acertos, p1, p2, cor, 2)
            cv2.putText(img_acertos, str(classe_pred), (p1[0], p1[1] - 5),
                        cv2.FONT_HERSHEY_SIMPLEX, 0.5, cor, 2)

    mm.show(
        img_acertos,
        title=f"{acertos}/{len(caixas_pred)} predições corretas "
            f"(classe + IoU >= 50%)",
        figsize=(6, 6)
    )
Figura 9.40: Predições corretas (verde) e incorretas (vermelho)

9.5.5 Aplicações Geométricas e Integradas

O capítulo encerra-se integrando dois pilares da VC: a geometria projetiva (estudada nos Capítulos 6 e 8) e o aprendizado profundo. A combinação dessas abordagens sustenta aplicações práticas no mundo real, como ilustrado a seguir.

9.5.5.1 Realidade Aumentada com Marcadores e Homografia

Imagine uma câmera apontada para uma mesa onde alguém colou um pequeno marcador ArUco. Dependendo do ângulo da câmera, esse marcador aparece rotacionado, inclinado, em perspectiva — nunca perfeitamente quadrado. É justamente essa distorção que a homografia sabe “ler” e desfazer (ou, no nosso caso, replicar para uma nova imagem).

O fluxo completo de uma aplicação de RA baseada em marcadores segue três passos:

  1. Ambientação: o marcador é inserido em uma cena real, sofrendo uma transformação de perspectiva (simulando o ângulo da câmera).
  2. Detecção: o algoritmo localiza o marcador na cena e recupera as coordenadas exatas dos seus 4 cantos com cv2.aruco.ArucoDetector.
  3. Substituição: com a homografia entre o marcador “ideal” e o marcador “detectado”, projeta-se uma nova imagem virtual exatamente sobre a área do marcador — como se ele tivesse se transformado em uma janela para outro conteúdo, conforme mostra a Figura 9.41.
Nota

Detalhe técnico importante: o ArUco precisa de uma margem branca ao redor do padrão (a “zona de silêncio”) para o detector conseguir diferenciar o marcador do fundo. Por isso, o código abaixo adiciona uma borda com cv2.copyMakeBorder e usa interpolação cv2.INTER_NEAREST ao deformar a imagem, evitando que a rotação borre os quadradinhos pretos e brancos e impeça a detecção.

# 1. Geração do marcador ArUco sintético, já com margem branca (quiet zone)
# → essa margem é essencial para o detector conseguir "enxergar" o marcador
dic = cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_4X4_50)
aruco_bruto = cv2.aruco.generateImageMarker(dic, 7, 200)
aruco_gray = cv2.copyMakeBorder(
    aruco_bruto, 40, 40, 40, 40, cv2.BORDER_CONSTANT, value=255
)  # 200 -> 280px, com 40px de moldura branca em cada lado
aruco = cv2.cvtColor(aruco_gray, cv2.COLOR_GRAY2BGR)
lado = aruco.shape[0]  # 280

# 2. Cena real de fundo, usando uma imagem de exemplo do skimage.data
fundo = cv2.cvtColor(skdata.coffee(), cv2.COLOR_RGB2BGR)
cena = cv2.resize(fundo, (640, 480))

# Cantos do marcador "de frente" (src) e sua posição rotacionada/inclinada na cena (dst)
src = np.float32([[0, 0], [lado, 0], [lado, lado], [0, lado]])
dst = np.float32([[190, 160], [420, 70], [470, 330], [150, 370]])  # rotação + perspectiva

# 3. Projeta o marcador (com bordas nítidas) sobre a cena real
H_cena, _ = cv2.findHomography(src, dst)
mask_cena = cv2.warpPerspective(
    np.full((lado, lado), 255, np.uint8), H_cena, (640, 480),
    flags=cv2.INTER_NEAREST,
)
cena[mask_cena > 0] = cv2.warpPerspective(
    aruco, H_cena, (640, 480), flags=cv2.INTER_NEAREST
)[mask_cena > 0]

# 4. Detecção do marcador dentro da cena (como uma câmera faria)
det = cv2.aruco.ArucoDetector(dic, cv2.aruco.DetectorParameters())
corners, ids, _ = det.detectMarkers(cena)

assert ids is not None and len(corners) > 0, \
    "Marcador não detectado — confira iluminação/contraste da cena."

# 5. Imagem virtual (outra imagem do skimage.data) que vai "substituir" o marcador
# Repare: usamos o quadrado INTERNO do marcador (sem a margem) como área de projeção,
# então a homografia da imagem virtual usa 'src' original (200x200), não o 'lado' com borda
src_interno = np.float32([[0, 0], [200, 0], [200, 200], [0, 200]])
virtual = cv2.resize(
    cv2.cvtColor(skdata.camera(), cv2.COLOR_RGB2BGR), (200, 200)
)

# Os cantos detectados correspondem ao marcador COM a margem (280x280),
# então recalculamos H_ra usando 'src' com margem, para manter a projeção coerente
H_ra, _ = cv2.findHomography(src, corners[0][0])

# A homografia detectada é aplicada à imagem virtual (redimensionada para 'lado')
virtual_grande = cv2.resize(virtual, (lado, lado))
ra = cena.copy()
mask_ra = cv2.warpPerspective(
    np.full((lado, lado), 255, np.uint8), H_ra, (640, 480), flags=cv2.INTER_NEAREST
)
ra[mask_ra > 0] = cv2.warpPerspective(
    virtual_grande, H_ra, (640, 480), flags=cv2.INTER_NEAREST
)[mask_ra > 0]

# Exibição: cena com marcador vs. cena com Realidade Aumentada aplicada
mm.show(
    [cv2.cvtColor(cena, cv2.COLOR_BGR2RGB), cv2.cvtColor(ra, cv2.COLOR_BGR2RGB)],
    titles=["Marcador na Cena Real (rotacionado)", "Sobreposição Virtual via Homografia"],
    cols=2,
    figsize=(9, 4),
)
Figura 9.41: Realidade aumentada baseada em marcador ArUco: marcador inserido em cena real e rotacionada, detectado e substituído por imagem virtual via homografia.

Resumo do pipeline:

Etapa O que faz Função-chave
1. Geração Marcador ArUco com margem branca generateImageMarker + copyMakeBorder
2. Ambientação Insere marcador distorcido na cena findHomography + warpPerspective
3. Detecção Localiza marcador e retorna cantos ArucoDetector.detectMarkers
4. Substituição Projeta imagem virtual sobre o marcador findHomography + warpPerspective

💡 Lição: detector “não encontrar nada” é comum em VC. Pergunte sempre: “dei contraste e espaço suficientes?” — vale para ArUco, QR codes e reconhecimento facial.

9.5.5.2 Fotogrametria e Referência de Escala

A fotogrametria permite estimar dimensões físicas de objetos a partir de imagens digitais. Para isso, utiliza-se um objeto de referência com dimensões conhecidas, posicionado na mesma cena do objeto de interesse. Esse procedimento estabelece uma relação entre distâncias medidas em pixels e suas correspondentes dimensões no mundo real.

Considere, por exemplo, um cartão de crédito, cujas dimensões seguem o padrão internacional ISO/IEC 7810. Como sua largura é exatamente 8,56 cm, basta determinar quantos pixels essa largura ocupa na imagem para calcular o fator de conversão entre pixels e centímetros. Se o cartão corresponder a 140 pixels, então cada pixel representará aproximadamente 0,061 cm. Esse mesmo fator de escala pode ser aplicado para estimar as dimensões de qualquer outro objeto localizado no mesmo plano da cena, conforme ilustrado na Figura 9.42.

O procedimento pode ser dividido em duas etapas principais:

  1. Segmentação e bounding box: localizar, na imagem, tanto o objeto de referência quanto o objeto de interesse, utilizando técnicas como segmentação por cor, limiarização, detecção de contornos ou métodos de detecção de objetos.
  2. Conversão para dimensões físicas: calcular a razão \(\mathrm{cm/pixel}\) a partir da largura conhecida do objeto de referência e utilizá-la para converter as medidas do objeto de interesse de pixels para centímetros.
Nota

Condição para medições confiáveis

A conversão entre pixels e centímetros pressupõe que o objeto de referência e o objeto de interesse estejam aproximadamente no mesmo plano e à mesma distância da câmera. Nessas condições, a escala permanece praticamente constante em toda a imagem. Diferenças de profundidade, inclinação da câmera ou distorções da lente podem introduzir erros nas medidas estimadas.

COR_REFERENCIA = (200, 200, 200)  # Cartão de referência (cinza)
COR_OBJETO = (60, 60, 220)  # Objeto alvo (vermelho)

# Desenho da cena sintética
cena_medicao = np.full((300, 500, 3), 255, dtype=np.uint8)
cv2.rectangle(
    cena_medicao, (30, 200), (30 + 140, 200 + 88), COR_REFERENCIA, -1
)
cv2.rectangle(cena_medicao, (250, 100), (250 + 220, 100 + 150), COR_OBJETO, -1)


def caixa_delimitadora_por_cor(imagem_bgr, cor_bgr, tolerancia=40):
    diferenca = np.abs(imagem_bgr.astype(int) - np.array(cor_bgr)).sum(axis=2)
    mascara = (diferenca < tolerancia).astype(np.uint8) * 255
    contornos, _ = cv2.findContours(
        mascara, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
    )
    maior_contorno = max(contornos, key=cv2.contourArea)
    return cv2.boundingRect(maior_contorno)


x_ref, y_ref, w_ref_px, h_ref_px = caixa_delimitadora_por_cor(
    cena_medicao, COR_REFERENCIA
)
x_obj, y_obj, w_obj_px, h_obj_px = caixa_delimitadora_por_cor(
    cena_medicao, COR_OBJETO
)

# Cálculo de escala física
LARGURA_REFERENCIA_CM = 8.56
razao_cm_por_px = LARGURA_REFERENCIA_CM / w_ref_px
largura_obj_cm = w_obj_px * razao_cm_por_px
altura_obj_cm = h_obj_px * razao_cm_por_px

# Desenho das caixas e medições estimadas
resultado = cena_medicao.copy()
cv2.rectangle(
    resultado, (x_ref, y_ref), (x_ref + w_ref_px, y_ref + h_ref_px), (0, 180, 0), 2
)
cv2.rectangle(
    resultado, (x_obj, y_obj), (x_obj + w_obj_px, y_obj + h_obj_px), (0, 180, 0), 2
)

cv2.putText(
    resultado,
    f"{LARGURA_REFERENCIA_CM:.2f} cm",
    (x_ref, y_ref - 8),
    cv2.FONT_HERSHEY_SIMPLEX,
    0.55,
    (0, 120, 0),
    2,
)

cv2.putText(
    resultado,
    f"{largura_obj_cm:.1f} x {altura_obj_cm:.1f} cm",
    (x_obj, y_obj - 8),
    cv2.FONT_HERSHEY_SIMPLEX,
    0.6,
    (0, 120, 0),
    2,
)

mm.show(
    [
        cv2.cvtColor(cena_medicao, cv2.COLOR_BGR2RGB),
        cv2.cvtColor(resultado, cv2.COLOR_BGR2RGB),
    ],
    titles=[
        "Cena Original",
        "Medição por Referência de Escala (Centímetros)",
    ],
    cols=2,
    figsize=(9, 4),
)
Figura 9.42: Medição de dimensões físicas reais utilizando um cartão de referência de escala conhecida (8,56 cm) renderizada. O retângulo cinza representa o cartão de referência e o retângulo azul representa o objeto alvo. As dimensões estimadas do objeto são exibidas em centímetros.

Resumo do pipeline:

Etapa O que faz Função-chave
1. Cena sintética Desenha o cartão de referência e o objeto alvo com cores distintas cv2.rectangle
2. Segmentação Isola cada objeto por cor e extrai seu contorno cv2.findContours
3. Bounding Box Obtém a caixa delimitadora (posição e tamanho em pixels) de cada objeto cv2.boundingRect
4. Escalonamento Converte pixels em centímetros usando a largura conhecida do cartão Regra de três: \(\text{cm/pixel} = \dfrac{8{,}56}{w_{ref\_px}}\)
5. Anotação Desenha as caixas e exibe as medidas estimadas sobre a imagem cv2.rectangle + cv2.putText

💡 Aplicação no mundo real: essa é exatamente a técnica usada por apps de e-commerce que estimam o tamanho de um produto a partir de uma foto ao lado de um cartão, por sistemas agrícolas que medem frutas em esteiras, e até por perícias forenses que calculam dimensões de vestígios em cenas de crime — tudo com a mesma ideia: uma régua conhecida dentro da própria foto.

9.6 Resumo

Este capítulo, que encerra a Parte II do livro, apresentou:

  • Convolução e pooling aprendidos: a mesma operação matemática de convolução do Capítulo 3, mas com kernels tratados como parâmetros ajustados por treinamento, em vez de definidos manualmente;

  • Compartilhamento de pesos e hierarquia de características como propriedades que tornam as CNNs eficientes e capazes de aprender representações cada vez mais abstratas em camadas sucessivas;

  • Treinamento de uma CNN do zero, com desempenho comparável — e não necessariamente superior — aos classificadores clássicos do Capítulo 7 em uma base pequena e simples, reforçando que a escolha do método deve ser proporcional à complexidade real do problema;

  • Transferência de aprendizado, demonstrada experimentalmente como uma estratégia eficaz para tarefas com poucos dados rotulados, reaproveitando um extrator de características já treinado em uma tarefa relacionada, e suas limitações, evidenciadas pela transferência negativa entre domínios muito distintos;

  • Aplicações em larga escala com modelos pré-treinados de classificação, detecção (Faster R-CNN) e segmentação (DeepLabV3), seguindo o mesmo princípio de transferência de aprendizado em escala industrial;

  • Transferência de aprendizado aplicada à detecção de objetos, ajustando um YOLO pré-treinado no COCO para localizar e classificar objetos geométricos sintéticos, ilustrando o mesmo princípio de congelamento parcial em um domínio de origem e destino ainda mais distantes entre si;

  • Segmentação semântica com U-Net, implementada e treinada do zero sobre um conjunto sintético de baixo contraste, superando uma linha de base clássica de limiarização graças às conexões de atalho entre codificador e decodificador;

  • Engenharia de dados para Visão Computacional com Roboflow, utilizando um dataset e um modelo pré-treinado para realizar inferência na detecção de objetos geométricos, além de comparar conjuntos de dados com diferentes resoluções, mas com as mesmas classes de objetos;

  • A integração de geometria computacional (homografia e calibração) e aprendizado profundo em duas aplicações reais que encerram o livro: realidade aumentada e fotogrametria.

9.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 09

🌐 Idioma e Linguagem de Programação

O projeto deste capítulo no Gemini Notebook foi construído apenas com o texto em português e os exemplos de código em Python. Se você está estudando pela edição em inglês ou francês, ou acompanhando a trilha em C++, as respostas do tutor podem não corresponder exatamente à versão que você está lendo.

⚠️ 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.

9.8 Lista de Exercícios

Os exercícios a seguir consolidam os conceitos apresentados neste capítulo por meio de adaptações, experimentos e extensões dos algoritmos desenvolvidos ao longo do texto, utilizando as bibliotecas PyTorch, ultralytics e a biblioteca didática morph.

  1. (10%) Investigue o impacto da profundidade em uma arquitetura convolucional. Partindo da rede de duas camadas do Projeto Prático 1, adicione uma terceira camada convolucional com 32 filtros antes das camadas totalmente conectadas. Treine a nova arquitetura mantendo o mesmo número de épocas e a mesma divisão dos dados. Compare a acurácia no conjunto de teste e o número total de parâmetros treináveis em relação à rede original, discutindo se o aumento de profundidade trouxe benefício mensurável para imagens de dimensão \(8 \times 8\).

  2. (15%) Avalie o limiar de dados necessários no domínio de destino para que o treinamento de uma CNN do zero se torne competitivo com a transferência de aprendizado. Variando o número de amostras de treino disponíveis no domínio B para \(\{5, 10, 20, 40, 80\}\), meça a acurácia de teste para ambas as estratégias. Apresente os resultados em um gráfico de linhas e determine a partir de qual volume de dados o treinamento do zero atinge desempenho equivalente ao extrator pré-treinado.

  3. (15%) Investigue a estratégia de ajuste fino parcial (fine-tuning) em comparação ao congelamento total de pesos. No cenário de transferência de aprendizado entre domínios de dígitos, descongele a segunda camada convolucional (conv2) do extrator para que ela seja atualizada juntamente com a cabeça de classificação durante o treinamento no domínio B. Compare a acurácia obtida com o congelamento total e com o treinamento do zero, discutindo o compromisso entre capacidade de adaptação e risco de sobreajuste (overfitting).

  4. (20%) Avalie a influência da profundidade do congelamento (freeze) no desempenho de detectores YOLO submetidos à transferência de aprendizado. Utilizando o conjunto de dados sintético de formas geométricas, execute o ajuste fino variando o parâmetro de congelamento do backbone para \(\{0, 5, 10, 15\}\). Registre a métrica \(\text{mAP}_{50}\) no conjunto de validação para cada configuração, apresente os dados em uma tabela e discuta se o congelamento parcial é vantajoso quando os domínios de origem (COCO) e de destino (formas geométricas) são significativamente distintos.

  5. (20%) Estude a importância das conexões de atalho (skip connections) na arquitetura U-Net para segmentação semântica. Implemente uma variação UNetSemAtalhos que funcione como um autoencoder convolucional tradicional, removendo as concatenações entre os estágios do codificador e do decodificador. Treine ambos os modelos sobre a mesma base de nódulos sintéticos, compare o IoU médio no conjunto de validação e apresente visualmente a diferença na precisão das bordas segmentadas por cada método.

  6. (20%) — Desafio: segmentação semântica com Roboflow. Utilize um projeto do Roboflow do tipo instance segmentation, contendo as sete classes de formas geométricas utilizadas neste capítulo. Exporte o dataset no formato coco-segmentation e desenvolva um procedimento para converter os polígonos armazenados nos arquivos _annotations.coco.json em máscaras semânticas multiclasse, nas quais cada pixel recebe o índice da classe correspondente e o valor 0 representa o fundo. Utilize as imagens e máscaras resultantes para treinar a UNetCompacta. Avalie o IoU médio no conjunto de teste e compare visualmente as máscaras preditas com as anotações originais. Discuta as principais dificuldades encontradas na conversão das anotações COCO para máscaras e os efeitos de objetos sobrepostos ou pertencentes a diferentes classes.

  7. (Bônus – 10%) Desenvolva um sistema interativo que combine detecção de objetos (YOLO) com medição por referência de escala (fotogrametria). Treine o detector para identificar duas classes em uma cena: um “Cartão de Referência” (dimensão conhecida de \(8{,}56\text{ cm} \times 5{,}39\text{ cm}\)) e um “Objeto Alvo”. Ao realizar a inferência em uma nova imagem, utilize a dimensão em pixels da bounding box do cartão detectado para converter as dimensões da caixa do objeto alvo em centímetros. Exiba a imagem processada com os rótulos de classe, a probabilidade de confiança e as dimensões físicas estimadas sobrepostas.

  8. (Bônus – 10%) Desenvolva um sistema de estimativa de profundidade por visão estéreo a partir de duas imagens da mesma cena obtidas de posições diferentes, simulando um par de câmeras estéreo. Considere que a distância entre as duas posições de captura (baseline) seja conhecida.

    Utilize um dos métodos de detecção de objetos apresentados no capítulo, como YOLO, para localizar os objetos de interesse nas duas imagens. Para cada detecção, estabeleça a correspondência entre o mesmo objeto nas duas posições e determine sua disparidade. A partir da disparidade, do baseline e dos parâmetros da câmera, utilize a geometria estéreo para estimar a distância de cada objeto em relação às câmeras.

    Como extensão da biblioteca didática morph, modifique o método showBoundBox para que, além da classe e da confiança da detecção, apresente sobre cada bounding box a distância estimada do objeto. O resultado deve permitir visualizar, diretamente nas imagens, a classe, a acurácia (confiança) e a profundidade de cada objeto detectado.

    Apresente as duas imagens com as detecções, as correspondências entre os objetos, a imagem de disparidade e uma representação da profundidade estimada. Discuta como a distância entre as câmeras, a precisão da detecção e da correspondência, a resolução das imagens e a posição do objeto na cena influenciam a qualidade da estimativa.

    Para validação, utilize pelo menos um objeto cuja distância à câmera seja conhecida. Compare a profundidade estimada com o valor real e reporte o erro absoluto e o erro relativo. Discuta também as limitações do método quando um objeto não é corretamente detectado nas duas imagens ou quando a correspondência entre as regiões observadas é ambígua.

9.9 Encerramento da Parte II

Este capítulo conclui a Parte II do livro e encerra a sequência de conteúdos iniciada no Capítulo 6, dedicada à representação, detecção, descrição e correspondência de características em imagens. Ao longo desses capítulos, foram apresentados métodos clássicos de VC baseados em características projetadas manualmente, como Sobel, LBP, HOG, ORB e Haar Cascade, bem como métodos fundamentados em características aprendidas automaticamente, representados pelas CNNs.

Os exemplos e experimentos desenvolvidos evidenciam que nenhuma dessas abordagens é universalmente superior. A escolha da técnica mais adequada depende das características do problema, da disponibilidade de dados para treinamento, dos requisitos de precisão e das restrições computacionais da aplicação. Em problemas bem estruturados e com poucos dados, descritores clássicos frequentemente oferecem soluções simples e eficientes. Em contrapartida, tarefas mais complexas tendem a se beneficiar da capacidade de aprendizado proporcionada pelas CNNs.

Diversas direções de estudo podem aprofundar os conceitos apresentados nesta parte do livro, entre as quais se destacam:

  • Arquiteturas modernas de CNN, como ResNet, EfficientNet e Vision Transformers, que ampliam a capacidade de representação e o desempenho em tarefas de classificação e reconhecimento visual;
  • Detecção e segmentação de objetos, com destaque para as famílias YOLO e para modelos de segmentação baseados em prompts, como o Segment Anything;
  • Reconstrução tridimensional e SLAM (Simultaneous Localization and Mapping), que utilizam múltiplas imagens para estimar a geometria da cena e a trajetória de câmeras em movimento;
  • Modelos generativos de imagens, como redes adversariais generativas (GANs) e modelos de difusão, capazes de sintetizar imagens realistas a partir de exemplos ou descrições textuais.

Os fundamentos desenvolvidos ao longo da Parte II constituem a base para essas e outras áreas avançadas da VC, nas quais a representação adequada das informações visuais permanece como elemento central para a análise e a compreensão de imagens.

Referências do Capítulo

Os conceitos e algoritmos apresentados neste capítulo foram fundamentados em referências clássicas e contemporâneas da literatura de Aprendizado Profundo aplicado à VC:

  • McCulloch; Pitts (1943), para a proposição da primeira abstração matemática e lógica do neurônio artificial, fundamentando as bases conceituais do processamento neural computacional.
  • Rosenblatt (1958), para a formulação original do Perceptron, modelo precursor do neurônio artificial utilizado nas arquiteturas modernas de aprendizado profundo.
  • Goodfellow; Bengio; Courville (2016) e LeCun; Bengio; Hinton (2015), para os fundamentos de redes neurais, convolução, funções de ativação e treinamento de modelos profundos.
  • Bishop (2006), para os conceitos rigorosos de reconhecimento de padrões, probabilidade, estimativa de máxima verossimilhança e métodos estatísticos aplicados ao aprendizado de máquina.
  • Ronneberger; Fischer; Brox (2015) para a arquitetura U-Net, utilizada na segmentação semântica com conexões de atalho entre codificador e decodificador.
  • Redmon et al. (2016), para a arquitetura YOLO (You Only Look Once), utilizada nos experimentos de detecção de objetos com transferência de aprendizado.
  • Ren et al. (2015), para a arquitetura Faster R-CNN, empregada como modelo pré-treinado de detecção de objetos.
  • He et al. (2016), para a arquitetura ResNet, base de diversos extratores de características pré-treinados utilizados neste capítulo.
  • Chen et al. (2018), para a arquitetura DeepLabV3, utilizada como modelo pré-treinado de segmentação semântica.
  • Kirillov et al. (2023), para o modelo Segment Anything (SAM), mencionado como direção de estudo para segmentação promptable.
  • Google (2025), referente à ferramenta Gemini Notebook, utilizada na elaboração do infográfico de síntese do capítulo e disponibilizada como apoio complementar ao estudo.

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

A presente lista de Exercícios de Programação (EP) consolida as formulações teóricas apresentadas ao longo do Capítulo 9 — Aprendizado Profundo para Visão Computacional — por meio de uma trilha prática aplicada. Diferentemente do treinamento de redes neurais completas com PyTorch, que exige tempo de execução e, por vezes, GPU, os EPs deste capítulo isolam as grandezas intermediárias de um pipeline real de aprendizado profundo — a saída de uma única camada convolucional, o resultado de uma operação de pooling, a contagem de parâmetros treináveis de uma arquitetura, a sobreposição entre caixas delimitadoras candidatas, a qualidade de uma máscara de segmentação e o filtro de supressão de não-máximos — permitindo validar manualmente cada etapa do raciocínio sem depender de bibliotecas de aprendizado de máquina nem de treinamento real.

O encadeamento dos exercícios reproduz o fluxo conceitual do capítulo e cresce em dificuldade a cada passo: inicia-se com o cálculo manual da saída de uma camada convolucional aprendida (🟢), a partir de um kernel e um viés já treinados; avança-se para a operação de pooling (🟢, máximo e média), que reduz a resolução espacial entre blocos convolucionais; prossegue-se com a contagem de parâmetros treináveis (🟡) de uma arquitetura CNN completa, evidenciando por que o compartilhamento de pesos torna essas redes tão mais econômicas que uma camada totalmente conectada equivalente; aprofunda-se no cálculo de Interseção sobre União (IoU) e na Supressão de Não-Máximos (NMS) (🟡), etapa de pós-processamento comum a detectores como o Faster R-CNN e o YOLO; segue para a avaliação de máscaras de segmentação (🟠) com as mesmas métricas de IoU e Dice usadas para comparar a U-Net com a linha de base morfológica clássica; e conclui-se com um pipeline integrado (🔴), unindo a saída de um detector de objetos (após NMS) a uma medição do mundo real por referência de escala — o mesmo princípio da fotogrametria estudada na integração final do capítulo.

Sempre que fizer sentido, cada exercício aponta métodos da biblioteca didática morph.py (a mesma usada ao longo do capítulo, importada como mm) que resolvem uma etapa do problema ou que servem de referência para conferir seus próprios cálculos — sem, no entanto, substituir o raciocínio que você deve implementar.

ImportanteDiretrizes para a Resolução dos Exercícios de Programação

Em todos os exercícios deste capítulo, as etapas de discretização ou arredondamento numérico devem empregar o arredondamento padrão para o inteiro mais próximo (round half away from zero), mitigando ambiguidades em valores com fração exatamente igual a \(0{,}5\). Salvo indicação explícita em contrário: (i) a operação de “convolução” segue a convenção adotada pelos frameworks de aprendizado profundo — correlação cruzada, sem inversão espacial do kernel, exatamente como apresentado na Seção “Camada Convolucional”; (ii) o preenchimento (padding) é feito com zeros; (iii) caixas delimitadoras são especificadas no formato canto-a-canto \((x_1, y_1, x_2, y_2)\), com \(x_1 < x_2\) e \(y_1 < y_2\); e (iv) vetores/matrizes seguem indexação a partir de \(0\), com a convenção [linha][coluna] para estruturas bidimensionais.

🎯 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, urllib.request

url = "https://raw.githubusercontent.com/fzampirolli/pdi-vc/master/morph/config.py"
if not os.path.exists("config.py"):
    urllib.request.urlretrieve(url, "config.py")

import config
config.setup(testsuite=True)
from morph import mm
from testsuite import TestSuite
✅ Ambiente pronto. Morph: 1.1.8 | OpenCV: 5.0.0 | TestSuite: 1.1.2

Executando os Testes

Para avaliar os testes, execute TestSuite("EP09_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 = """
# ... seu código aqui ...
"""
TestSuite("EP09_01").run_code(codigo)

9.10.1 EP09_01 🟢 Convolução 2D Manual (Forward de uma Camada Aprendida)

O PyTorch, apresentado neste capítulo, executa nn.Conv2d(x) em uma única chamada — mas por trás dela está apenas a correlação cruzada entre um kernel (já treinado) e uma vizinhança da entrada, seguida da soma de um viés e de uma ativação, exatamente como formalizado na Seção “Camada Convolucional”. A diferença essencial em relação à convolução de kernels fixos do Capítulo 3 é que, aqui, os valores do kernel e do viés já vêm prontos (como se tivessem sido aprendidos por gradiente), e cabe a você reproduzir manualmente a passagem direta (forward pass) que o framework executa internamente.

Antes de treinar uma CNN de verdade, você foi encarregado de implementar essa passagem direta do zero, para uma única camada convolucional com um único canal de entrada e um único filtro de saída, incluindo suporte a padding e stride arbitrários.

9.10.1.1 📋 Diretrizes de Implementação

  1. Entrada: Ler as dimensões \(H \times W\) do mapa de características de entrada e, em seguida, seus \(H \times W\) valores reais.

  2. Kernel e viés: Ler as dimensões \(k_h \times k_w\) do kernel (já treinado), seus valores reais, e o viés \(b\) (real, escalar).

  3. Hiperparâmetros: Ler o padding \(p\) (inteiro, número de zeros adicionados em cada borda) e o stride \(s\) (inteiro, passo do deslizamento).

  4. Preenchimento: Adicionar \(p\) zeros em cada uma das quatro bordas do mapa de entrada antes da correlação.

  5. Correlação cruzada: Para cada posição de saída \((i, j)\), calcular \[ z(i,j) = b + \sum_{u=0}^{k_h-1} \sum_{v=0}^{k_w-1} K(u,v) \cdot X_{pad}(i \cdot s + u,\; j \cdot s + v), \] percorrendo a entrada sem inverter o kernel (convenção dos frameworks de aprendizado profundo, diferente da convolução matemática clássica).

  6. Ativação: Aplicar ReLU a cada valor: \(a(i,j) = \max(0, z(i,j))\).

  7. Dimensões de saída: \(O_h = \lfloor (H + 2p - k_h)/s \rfloor + 1\) e \(O_w = \lfloor (W + 2p - k_w)/s \rfloor + 1\).

  8. Saída: Imprimir \(O_h\) e \(O_w\) na primeira linha, seguidos de \(O_h\) linhas com \(O_w\) valores reais cada (o mapa de características de saída, já com ReLU aplicada), formatados com 4 casas decimais.

9.10.1.2 📌 Restrições Computacionais

  • Um canal de entrada, um filtro de saída: não é necessário lidar com múltiplos canais ou múltiplos filtros nesta versão simplificada.
  • Sem inversão do kernel: implemente correlação cruzada, não a convolução matemática clássica com kernel invertido — é essa a operação que o PyTorch (e a maioria dos frameworks) chama de “convolução”.
  • Preenchimento por zeros: os \(p\) pixels adicionados em cada borda valem sempre \(0\).
  • Formatação: todos os valores de saída devem ter exatamente 4 casas decimais, mesmo quando o valor é inteiro (ex.: 2.0000).

9.10.1.3 🧠 Fundamentação Teórica

Elemento Papel na camada convolucional
Kernel \(K\) Parâmetros aprendidos por gradiente, análogos aos coeficientes de um filtro fixo do Capítulo 3, mas ajustados por retropropagação
Viés \(b\) Deslocamento aprendido, somado após a correlação — permite que o neurônio “dispare” mesmo com entrada nula
Padding Controla a dimensão espacial de saída e evita a perda de informação nas bordas a cada camada
Stride Controla o passo do deslocamento; valores \(> 1\) reduzem a resolução espacial, como uma forma de subamostragem embutida na própria convolução
ReLU Introduz não linearidade após a combinação linear, exatamente como na Seção “Função de Ativação”

9.10.1.4 🧩 Métodos do morph.py que podem ajudar

  • mm.readImg(h, w, dtype='float') — lê diretamente uma matriz \(h \times w\) de valores reais da entrada padrão, poupando o parsing manual do mapa de características e do kernel.
  • mm.correlacao0(f, kernel, bias) — implementa a mesma soma de correlação cruzada + viés que você vai calcular à mão, mas sem suporte a padding ou stride, e converte o resultado para uint8 (trunca valores negativos e decimais). Pode servir de referência conceitual ou para conferir o caso mais simples (\(p=0\), \(s=1\)), mas não substitui sua implementação completa — que precisa preservar sinal, casas decimais, padding, stride e ReLU.

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

Entrada:

  • Linha 1: Inteiros \(H\) e \(W\).
  • Próximas \(H\) linhas: \(W\) valores reais cada (mapa de entrada).
  • Próxima linha: Inteiros \(k_h\) e \(k_w\).
  • Próximas \(k_h\) linhas: \(k_w\) valores reais cada (kernel).
  • Próxima linha: Real \(b\) (viés).
  • Próxima linha: Inteiros \(p\) e \(s\).

Saída:

  • Linha 1: Inteiros \(O_h\) e \(O_w\).
  • Próximas \(O_h\) linhas: \(O_w\) valores reais cada, com 4 casas decimais.

9.10.1.6 📌 Exemplos

Entrada Saída Observação
3 3
1 2 0
0 1 2
1 0 1
2 2
1 1
1 1
-2
0 1
2 2
2.0000 3.0000
0.0000 2.0000
Padding 0, stride 1: saída \(2\times2\) sem preenchimento.
3 3
1 2 0
0 1 2
1 0 1
2 2
1 0
0 1
0
1 2
2 2
1.0000 0.0000
1.0000 2.0000
Padding 1, stride 2: entrada preenchida com zeros antes da correlação.
🎮 Simulador: Convolução 2D Manual 🟢 correlação cruzada + viés + ReLU

Entrada 4×4 fixa, kernel 2×2 fixo (destacado em azul) — ajuste padding (p), stride (s) e viés (b), exatamente os parâmetros que o EP09_01 pede na entrada, e veja como eles mudam o tamanho e os valores da saída.

Padding (p)
Stride (s)
Viés (b)
(0,0)
Entrada X preenchida (com padding)
original padding (0) janela atual
Kernel K (2×2)
Saída Y = ReLU(X⊛K + b)

💡 Cada posição do slider revela uma célula da matriz de saída. Percorra todas as posições para completar o mapa de saída. Trocar p, s ou b reinicia a exploração, porque o mapa de saída muda de tamanho e/ou de valores.

Figura 9.43: Simulador EP09_01: Convolução 2D Manual (correlação cruzada + viés + ReLU, com padding e stride ajustáveis)
%%writefile EP09_01.py
# Código Python
Writing EP09_01.py
TestSuite("EP09_01.py").run()
✔️ EP09_01.cases já existe em casos/
📋 3 caso(s) carregado(s) de casos/EP09_01.cases

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

9.10.2 EP09_02 🟢 Pooling Manual (Máximo e Média)

Entre blocos convolucionais, a arquitetura típica de uma CNN intercala camadas de pooling, que reduzem a resolução espacial do mapa de características sem introduzir novos parâmetros treináveis — ao contrário da convolução, o pooling não tem pesos: ele apenas resume cada janela da entrada a um único valor, por um máximo ou por uma média, exatamente como formalizado na Seção “Pooling”.

Você foi encarregado de implementar essa operação a partir de uma janela deslizante quadrada, sem sobreposição parcial nas bordas (apenas janelas completas), suportando os dois tipos mais comuns: max (preserva o valor mais saliente, tipicamente usado para reter bordas e texturas fortes) e avg (suaviza a região, preservando informação de intensidade média).

9.10.2.1 📋 Diretrizes de Implementação

  1. Entrada: Ler as dimensões \(H \times W\) do mapa de características de entrada e seus \(H \times W\) valores reais.
  2. Janela: Ler os inteiros \(k\) (tamanho da janela quadrada \(k \times k\)) e \(s\) (stride).
  3. Tipo: Ler uma string, max ou avg, indicando o tipo de pooling.
  4. Sem preenchimento: Esta operação não utiliza padding; janelas que ultrapassariam a borda da entrada são descartadas.
  5. Cálculo: Para cada posição de saída \((i,j)\), calcular o máximo ou a média dos \(k \times k\) valores da janela correspondente, iniciando em \((i \cdot s,\, j \cdot s)\).
  6. Dimensões de saída: \(O_h = \lfloor (H - k)/s \rfloor + 1\) e \(O_w = \lfloor (W - k)/s \rfloor + 1\).
  7. Saída: Imprimir \(O_h\) e \(O_w\) na primeira linha, seguidos de \(O_h\) linhas com \(O_w\) valores reais cada, formatados com 4 casas decimais.

9.10.2.2 📌 Restrições Computacionais

  • Janela quadrada: \(k \times k\), sem suporte a janelas retangulares nesta versão.
  • Sem padding: apenas janelas inteiramente contidas na entrada são consideradas — dimensões que “sobram” são simplesmente descartadas.
  • avg usa divisão real: a média é sempre \(\text{soma}/k^2\), mesmo quando o resultado tem muitas casas decimais — arredonde apenas na formatação final, conforme a diretriz geral do capítulo.
  • Formatação: todos os valores de saída com exatamente 4 casas decimais.

9.10.2.3 🧠 Fundamentação Teórica

Elemento Papel na arquitetura
Pooling máximo Preserva a ativação mais forte da janela; comum após camadas convolucionais para reter bordas e texturas salientes
Pooling médio Suaviza a região, preservando a intensidade média; comum em camadas finais (global average pooling)
Ausência de parâmetros Diferencia o pooling da convolução: reduz resolução espacial sem custo adicional de treinamento
Redução de resolução Contribui para a invariância a pequenas translações e para a redução do custo computacional das camadas seguintes

9.10.2.4 🧩 Métodos do morph.py que podem ajudar

O morph.py não implementa pooling com subamostragem diretamente, mas duas famílias de operações mostram a mesma ideia sob outra ótica, útil para checar sua intuição:

  • mm.dil(f, Bc) / mm.dil0(f, B) — dilatação morfológica: substitui cada pixel pelo máximo de sua vizinhança definida pelo elemento estruturante \(B\) (ex.: mm.sebox(n) para uma janela \((2n+1)\times(2n+1)\)). É, conceitualmente, um “max-pooling sem subamostragem” (produz uma imagem do mesmo tamanho, em vez de reduzida).
  • mm.blur(f, N) — suavização por média em uma janela \(N \times N\), análoga ao avg-pooling, também sem redução de resolução.
  • mm.readImg(h, w, dtype='float') — útil para ler o mapa de entrada em ponto flutuante.

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

Entrada:

  • Linha 1: Inteiros \(H\) e \(W\).
  • Próximas \(H\) linhas: \(W\) valores reais cada.
  • Próxima linha: Inteiros \(k\) e \(s\).
  • Próxima linha: max ou avg.

Saída:

  • Linha 1: Inteiros \(O_h\) e \(O_w\).
  • Próximas \(O_h\) linhas: \(O_w\) valores reais cada, com 4 casas decimais.

9.10.2.6 📌 Exemplos

Entrada Saída Observação
4 4
1 3 2 4
5 6 1 2
2 1 0 3
4 2 5 1
2 2
max
2 2
6.0000 4.0000
4.0000 5.0000
Pooling máximo, janela \(2\times2\), stride 2.
4 4
1 3 2 4
5 6 1 2
2 1 0 3
4 2 5 1
2 2
avg
2 2
3.7500 2.2500
2.2500 2.2500
Pooling médio sobre as mesmas janelas.
🎮 Simulador: Pooling Manual 🟢 sem padding, janelas completas

Entrada 4×4 fixa — ajuste o tamanho da janela (k), o stride (s) e o tipo, exatamente os parâmetros que o EP09_02 lê na entrada, e veja como eles mudam o tamanho e os valores da saída.

Janela (k)
Stride (s)
Tipo
(0,0)
Entrada X (4×4)
fora da janela janela atual descartado (sobra)
Saída Y (pooling)

💡 Cada posição do slider revela uma célula da matriz de saída. Células cinza-tracejadas na entrada são "sobras" que nenhuma janela alcança — note como isso acontece quando (H−k) não é múltiplo de s. Trocar k, s ou o tipo reinicia a exploração.

Figura 9.44: Simulador EP09_02: Pooling Manual (máximo vs. média, com janela k e stride s ajustáveis)
%%writefile EP09_02.py
# Código Python
Writing EP09_02.py
TestSuite("EP09_02.py").run()
✔️ EP09_02.cases já existe em casos/
📋 4 caso(s) carregado(s) de casos/EP09_02.cases

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

9.10.3 EP09_03 🟡 Contagem de Parâmetros Treináveis de uma CNN

Este EP formaliza a contagem de parâmetros treináveis de uma CNN. Dada a descrição textual de uma pequena arquitetura, composta por camadas convolucionais, de pooling e totalmente conectadas, determine, para cada camada, o número de parâmetros treináveis e o total da rede.

A arquitetura deve ser interpretada sequencialmente: a saída de uma camada convolucional torna-se a entrada da próxima camada compatível. Assim, o número de canais produzidos por uma camada CONV determina o número de canais de entrada (cin) da camada convolucional seguinte.

Em uma camada convolucional, é importante distinguir canais de entrada e canais de saída:

  • \(c_{in}\) (channels in) é o número de canais que entram na camada. Uma imagem em tons de cinza possui \(c_{in}=1\), enquanto uma imagem RGB possui \(c_{in}=3\). Em uma camada convolucional intermediária, cin normalmente é igual ao número de canais produzidos pela camada CONV anterior.
  • \(c_{out}\) (channels out) é o número de canais produzidos pela camada. Ele é igual ao número de filtros utilizados. Portanto, se uma camada possui 16 filtros, ela produz \(c_{out}=16\) canais.

Por exemplo, considere a sequência:

CONV 3 3 1 8 1
POOL
CONV 3 3 8 16 1
POOL
FC 784 10 1

A primeira convolução recebe uma imagem com um canal e produz 8 canais. Depois do pooling, a segunda convolução recebe esses 8 canais e produz 16 canais. A camada POOL não altera o número de canais, apenas pode reduzir as dimensões espaciais. A camada FC recebe a quantidade de entradas informada na própria descrição.

Cada filtro convolucional possui dimensões

\[ k_h \times k_w \times c_{in}. \]

Assim, uma camada com \(c_{out}\) filtros possui

\[ k_h \cdot k_w \cdot c_{in} \cdot c_{out} \]

pesos. Se houver viés, acrescenta-se um parâmetro para cada filtro, totalizando mais \(c_{out}\) parâmetros.

O ponto central deste exercício é observar que a quantidade de parâmetros de uma camada convolucional não depende das dimensões espaciais (\(H \times W\)) do mapa de características. Isso ocorre devido ao compartilhamento de pesos: o mesmo filtro é reutilizado em diferentes posições da entrada.

9.10.3.1 📋 Diretrizes de Implementação

  1. Entrada: Ler o inteiro \(L\) (número de camadas da arquitetura, na ordem em que são aplicadas).

  2. Camadas: Ler \(L\) linhas, cada uma descrevendo uma camada em um dos três formatos:

    • CONV kh kw cin cout bias — camada convolucional com kernel \(k_h \times k_w\), \(c_{in}\) canais de entrada, \(c_{out}\) canais de saída e bias (0 ou 1), indicando se há viés por filtro;
    • POOL — camada de pooling (máximo ou médio), que não possui parâmetros treináveis e preserva o número de canais;
    • FC in out bias — camada totalmente conectada com in entradas, out saídas e bias (0 ou 1), indicando se há viés por neurônio.
  3. Consistência entre camadas CONV: em uma sequência de camadas convolucionais, o cin de uma camada deve corresponder ao cout da camada convolucional anterior. Uma camada POOL não altera esse número de canais.

    Por exemplo:

    CONV 3 3 1 8 1
    POOL
    CONV 3 3 8 16 1

    A primeira CONV produz 8 canais, que são recebidos pela segunda CONV. Portanto, na segunda camada, cin=8 e cout=16.

  4. Parâmetros de uma camada CONV:

    Cada um dos \(c_{out}\) filtros possui \(k_h \cdot k_w \cdot c_{in}\) pesos. Portanto,

    \[ P_{\mathrm{CONV}} = k_h \cdot k_w \cdot c_{in} \cdot c_{out} + c_{out}\cdot\text{bias}. \]

  5. Parâmetros de uma camada FC:

    \[ P_{\mathrm{FC}} = \text{in}\cdot\text{out} + \text{out}\cdot\text{bias}. \]

  6. Parâmetros de uma camada POOL: sempre \(0\).

  7. Total da rede: somar os parâmetros treináveis de todas as camadas.

  8. Saída: Para cada camada, na ordem de leitura, imprimir Camada i: P, em que \(i\) começa em \(1\) e \(P\) é o número de parâmetros daquela camada. Ao final, imprimir Total: T.

9.10.3.2 📐 Exemplo para entender cin e cout

Considere a sequência:

CONV 3 3 1 8 1
POOL
CONV 3 3 8 16 1

Na primeira camada:

  • cin=1: entra um canal;
  • cout=8: existem 8 filtros e, portanto, saem 8 canais.

Cada filtro possui

\[ 3\cdot3\cdot1=9 \]

pesos. Como existem 8 filtros:

\[ 9\cdot8=72 \]

pesos. Com um viés por filtro:

\[ 72+8=80. \]

Na segunda camada:

  • cin=8: entram os 8 canais produzidos pela primeira CONV;
  • cout=16: existem 16 filtros e, portanto, saem 16 canais.

Cada filtro possui

\[ 3\cdot3\cdot8=72 \]

pesos. Como existem 16 filtros:

\[ 72\cdot16=1152 \]

pesos. Com 16 vieses:

\[ 1152+16=1168. \]

Assim, as duas camadas possuem, respectivamente, 80 e 1168 parâmetros treináveis.

Observe que cout não é \(cin\) multiplicado pelo número de filtros. O número de filtros é exatamente cout: cada filtro combina todos os canais de entrada e produz um único canal de saída.

9.10.3.3 📌 Restrições Computacionais

  • Independência da dimensão espacial: a entrada não informa \(H \times W\). A contagem de uma camada CONV depende apenas de kh, kw, cin e cout.
  • Consistência dos canais: para duas camadas CONV consecutivas, o cin da segunda deve ser igual ao cout da primeira. Uma camada POOL preserva o número de canais.
  • bias sempre 0 ou 1: multiplique diretamente o termo de viés por esse valor.
  • Camadas POOL sem argumentos adicionais: a linha contém apenas a palavra POOL.
  • Camadas FC: o número de entradas in é fornecido explicitamente. Não é necessário calcular as dimensões espaciais produzidas pelas camadas anteriores.
  • Todos os valores numéricos de entrada são inteiros não negativos.

9.10.3.4 🧠 Fundamentação Teórica

Elemento Papel na contagem de parâmetros
\(c_{in}\) Número de canais recebidos pela camada
\(c_{out}\) Número de filtros e, portanto, de canais produzidos pela camada
Filtro convolucional Cada filtro possui \(k_h \cdot k_w \cdot c_{in}\) pesos e produz um canal de saída
Compartilhamento de pesos O mesmo filtro é reutilizado em diferentes posições da entrada, tornando a contagem independente de \(H \times W\)
Viés Um único parâmetro adicional por filtro (CONV) ou por neurônio (FC)
Pooling Pode alterar \(H \times W\), mas não possui parâmetros treináveis e preserva o número de canais
Camada FC Possui um peso para cada combinação entre entrada e neurônio de saída

9.10.3.5 🧩 Métodos do morph.py que podem ajudar

Este exercício é puramente aritmético e não utiliza diretamente funções do morph.py. A contagem pode, entretanto, ser conferida em uma arquitetura real implementada em PyTorch por meio de:

sum(p.numel() for p in modelo.parameters())

Essa expressão contabiliza os parâmetros do modelo, incluindo pesos e vieses.

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

Entrada:

  • Linha 1: Inteiro \(L\).
  • Próximas \(L\) linhas: descrição de cada camada, no formato CONV kh kw cin cout bias, POOL ou FC in out bias.

Saída:

  • \(L\) linhas no formato Camada i: P.
  • Última linha: Total: T.

9.10.3.7 📌 Exemplos

Entrada Saída Observação
3
CONV 3 3 1 8 1
POOL
FC 1352 10 1
Camada 1: 80
Camada 2: 0
Camada 3: 13530
Total: 13610
Rede simples com uma convolução, pooling e camada de classificação.
5
CONV 3 3 1 8 1
POOL
CONV 3 3 8 16 1
POOL
FC 400 10 1
Camada 1: 80
Camada 2: 0
Camada 3: 1168
Camada 4: 0
Camada 5: 4010
Total: 5258
Pequena CNN com duas convoluções, dois poolings e uma camada totalmente conectada.
6
CONV 3 3 1 8 1
POOL
CONV 3 3 8 16 1
POOL
FC 256 32 1
FC 32 10 1
Camada 1: 80
Camada 2: 0
Camada 3: 1168
Camada 4: 0
Camada 5: 8224
Camada 6: 330
Total: 9802
CNN pequena com duas convoluções, pooling intermediário e duas camadas totalmente conectadas para classificação.
🎮 Simulador: Contagem de Parâmetros 🟡 compartilhamento de pesos
CONV Bloco azul POOL Cilindro verde FC Losango laranja BATCH Pilha vermelha 🖱️ Arraste para mover camadas
32×32
1
4
3
🧠 Visualização 3D
🖱️ Arraste camadas | Scroll zoom | P pausar
Figura 9.45: Simulador EP09_03: Contagem de Parâmetros — Convolução vs. Camada Totalmente Conectada
%%writefile EP09_03.py
# Código Python
Writing EP09_03.py
TestSuite("EP09_03.py").run()
✔️ EP09_03.cases já existe em casos/
📋 3 caso(s) carregado(s) de casos/EP09_03.cases

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

9.10.4 EP09_04 🟡 Interseção sobre União (IoU) e Supressão de Não-Máximos (NMS)

Modelos de detecção de objetos podem produzir várias caixas delimitadoras candidatas para um mesmo objeto, com diferentes posições e pontuações de confiança. A etapa de pós-processamento responsável por eliminar essas detecções redundantes é a Supressão de Não-Máximos (NMS), cuja operação fundamental utiliza a métrica de Interseção sobre União (IoU).

A NMS utiliza essa medida para decidir quais caixas devem ser mantidas. Em geral, a caixa com maior confiança é selecionada primeiro; em seguida, caixas que apresentam IoU acima de um determinado limiar com a caixa selecionada são consideradas redundantes e removidas. O processo é repetido até que não restem caixas candidatas.

Neste exercício, você deverá implementar o algoritmo de NMS do zero, calculando a IoU entre caixas e aplicando sucessivamente o critério de seleção e supressão para produzir o conjunto final de detecções.

9.10.4.1 📋 Diretrizes de Implementação

  1. Entrada: Ler o inteiro \(N\) (número de caixas candidatas) e o limiar real \(\tau\) (limiar de IoU para supressão), na mesma linha.

  2. Caixas: Ler \(N\) linhas, cada uma com cinco valores reais:

    x1 y1 x2 y2 score

    em que \((x_1,y_1)\) representa o canto superior esquerdo, \((x_2,y_2)\) o canto inferior direito e score a pontuação de confiança.

  3. Interseção sobre União: Para duas caixas \(A\) e \(B\),

    \[ IoU(A,B)= \frac{\operatorname{Área}(A\cap B)} {\operatorname{Área}(A\cup B)}. \]

    A área de interseção deve ser calculada a partir da sobreposição dos intervalos em \(x\) e \(y\). Se não houver sobreposição, a área de interseção é zero.

  4. Algoritmo guloso de NMS:

    1. Ordene as caixas por score decrescente. Em caso de empate, mantenha a ordem original de leitura.

    2. Selecione a caixa de maior pontuação entre as caixas restantes e adicione-a ao conjunto de saída.

    3. Calcule o IoU entre a caixa selecionada e todas as caixas ainda restantes. Suprima as caixas para as quais

    \[ \text{IoU} > \tau. \]

    1. Repita os passos (b) e (c) até que não restem caixas.
  5. Saída: Para cada caixa mantida, na ordem em que foi selecionada, imprimir seu índice original (posição de leitura, começando em \(0\)) e seu score, formatado com 4 casas decimais. Ao final, imprimir:

    Total mantidas: X

9.10.4.2 📌 Restrições Computacionais

  • Supressão estrita: apenas caixas com \(\text{IoU} > \tau\) são suprimidas. Caixas com \(\text{IoU}=\tau\) são mantidas.
  • Índices originais: a saída referencia a posição em que cada caixa foi lida na entrada (começando em \(0\)), e não sua posição após a ordenação.
  • Ordenação estável: em caso de score iguais, deve ser preservada a ordem original de leitura.
  • Retângulos alinhados aos eixos: todas as caixas são especificadas por dois cantos, com \(x_1 < x_2\) e \(y_1 < y_2\) garantidos na entrada.
  • Coordenadas e pontuações: os valores reais podem ser positivos ou negativos, conforme os limites definidos pela entrada, mas as dimensões das caixas são sempre positivas.

9.10.4.3 🧠 Fundamentação Teórica

Elemento Papel no pós-processamento de detecção
IoU Quantifica a sobreposição espacial entre duas caixas; \(\text{IoU}=1\) para caixas idênticas e \(\text{IoU}=0\) para caixas sem sobreposição
Ordenação por confiança Faz com que a caixa de maior score seja analisada primeiro
Limiar \(\tau\) Define a quantidade de sobreposição necessária para que uma caixa seja considerada redundante
Supressão Remove caixas que apresentam grande sobreposição com uma caixa já selecionada
Caixas distantes Possuem IoU próximo de zero e, em geral, não são suprimidas por essa regra

9.10.4.4 🧩 Métodos do morph.py que podem ajudar

  • mm.IoU(boxA, boxB) — calcula a métrica de IoU, mas espera as caixas no formato \((x,y,w,h)\), isto é, canto superior esquerdo, largura e altura. A entrada deste exercício utiliza o formato \((x_1,y_1,x_2,y_2)\). A conversão é direta:

    \[ w=x_2-x_1,\qquad h=y_2-y_1. \]

    O uso dessa função é opcional. O objetivo principal do exercício é implementar corretamente o processo de seleção e supressão da NMS.

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

Entrada:

  • Linha 1: inteiro \(N\) e real \(\tau\).
  • Próximas \(N\) linhas: \(x_1\ y_1\ x_2\ y_2\ \text{score}\).

Saída:

  • Uma linha por caixa mantida, na ordem de seleção: índice score.
  • Última linha: Total mantidas: X.
🎮 Simulador: IoU e Supressão de Não-Máximos 🟡 NMS
Caixa selecionada Caixa mantida Caixa suprimida Caixa candidata
5
0.50
Padrão
🎯 Visualização das Caixas
📋 Passo a Passo da NMS
Figura 9.46: Simulador EP09_04: IoU e Supressão de Não-Máximos (NMS)
%%writefile EP09_04.py
# Código Python
Writing EP09_04.py
TestSuite("EP09_04.py").run()
✔️ EP09_04.cases já existe em casos/
📋 3 caso(s) carregado(s) de casos/EP09_04.cases

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

9.10.5 EP09_05 🟠 Avaliação de Segmentação: IoU e Dice Pixel a Pixel

O Bloco 2 da seção “Segmentação Semântica com Arquitetura U-Net” define, em poucas linhas, a função iou_mascaras, usada para medir a qualidade da linha de base morfológica clássica (suavização + Otsu + abertura) e, mais adiante, da própria U-Net treinada. Diferentemente do IoU do EP09_04 — calculado sobre caixas delimitadoras (regiões retangulares descritas por quatro números) —, o IoU de segmentação é calculado pixel a pixel: cada posição da imagem é comparada individualmente entre a máscara predita e a máscara de referência.

Você foi encarregado de generalizar essa avaliação, implementando não apenas o IoU pixel a pixel, mas também o coeficiente de Dice, outra métrica de sobreposição amplamente usada em segmentação médica (inclusive na função perda_dice, mencionada no mesmo bloco do capítulo como base da função de perda usada para treinar a U-Net).

9.10.5.1 📋 Diretrizes de Implementação

  1. Entrada: Ler as dimensões \(H \times W\) das máscaras.

  2. Máscara predita: Ler \(H\) linhas com \(W\) valores inteiros (0 ou 1) cada — por exemplo, a saída de uma U-Net após limiarização em \(0{,}5\) sobre a sigmoide, como no Bloco 4 do capítulo.

  3. Máscara de referência: Ler mais \(H\) linhas com \(W\) valores inteiros (0 ou 1) cada — o ground truth.

  4. Interseção e união: Considerando cada pixel como pertencente ao objeto quando seu valor é diferente de zero, \[ \text{interseção} = \sum_{i,j} \mathbb{1}[P_{ij}=1 \wedge R_{ij}=1], \qquad \text{união} = \sum_{i,j} \mathbb{1}[P_{ij}=1 \vee R_{ij}=1]. \]

  5. IoU pixel a pixel: \[ \text{IoU} = \frac{\text{interseção}}{\text{união}}. \]

  6. Coeficiente de Dice: \[ \text{Dice} = \frac{2 \cdot \text{interseção}}{|P| + |R|}, \] em que \(|P|\) e \(|R|\) são o número total de pixels de objeto em cada máscara.

  7. Convenção para máscaras vazias: se ambas as máscaras não possuem nenhum pixel de objeto (união \(= 0\) e \(|P|+|R|=0\)), considere a correspondência trivialmente perfeita: \(\text{IoU} = \text{Dice} = 1{,}0\).

  8. Saída: Duas linhas, IoU: X.XXXX e Dice: X.XXXX, cada valor com 4 casas decimais.

9.10.5.2 📌 Restrições Computacionais

  • Qualquer valor não nulo conta como objeto: trate valores diferentes de \(0\) (não apenas \(1\)) como pertencentes à máscara, replicando a checagem predita > 0 usada em iou_mascaras no capítulo.
  • Mesmas dimensões: as duas máscaras sempre possuem exatamente \(H \times W\) elementos.
  • Convenção de vazio: aplique a regra do item 7 apenas quando ambas as máscaras estiverem totalmente vazias; se apenas uma estiver vazia, a interseção é \(0\) e o IoU/Dice resultante também será \(0\).

9.10.5.3 🧠 Fundamentação Teórica

Elemento Papel na avaliação de segmentação
IoU pixel a pixel Generaliza a métrica do EP09_04 para regiões de forma arbitrária — não apenas retângulos — comparando máscara predita e referência posição a posição
Coeficiente de Dice Métrica relacionada ao IoU (sempre \(\text{Dice} \ge \text{IoU}\)), mais sensível a pequenas interseções e amplamente usada como função de perda em segmentação (função perda_dice do capítulo)
Convenção de máscaras vazias Evita divisão por zero e reconhece que “nenhum objeto previsto, nenhum objeto real” é, por definição, um acerto
Comparação clássico vs. U-Net O capítulo usa exatamente este tipo de métrica para justificar, numericamente, por que a U-Net supera a linha de base morfológica em cenários de baixo contraste

9.10.5.4 🧩 Métodos do morph.py que podem ajudar

  • mm.readImg(h, w, dtype='uint8') — lê diretamente cada máscara binária \(h \times w\) da entrada padrão (os valores \(0/1\) cabem perfeitamente no tipo inteiro padrão).
  • A própria função iou_mascaras, definida no Bloco 2 da seção de U-Net do capítulo (não faz parte do morph.py, mas do código do capítulo), é a inspiração direta deste exercício — vale reler aquelas poucas linhas antes de programar.
  • Para uma extensão opcional (não exigida por este EP), mm.connectedComponents ou mm.label0 (vistos no contexto de análise de componentes conexos) permitiriam rotular cada nódulo individualmente e calcular o IoU por componente, em vez de sobre a máscara inteira.

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

Entrada:

  • Linha 1: Inteiros \(H\) e \(W\).
  • Próximas \(H\) linhas: \(W\) valores inteiros (0 ou 1) — máscara predita.
  • Próximas \(H\) linhas: \(W\) valores inteiros (0 ou 1) — máscara de referência.

Saída:

  • Linha 1: IoU: X.XXXX.
  • Linha 2: Dice: X.XXXX.
Dica💡 Exemplo Ilustrativo

Considere uma máscara predita com um quadrado \(2\times2\) de pixels ativos e uma referência deslocada em uma coluna, sobrepondo-se em apenas metade da área:

Predita         Referência
0 0 0 0         0 0 0 0
0 1 1 0         0 0 1 1
0 1 1 0         0 0 1 1
0 0 0 0         0 0 0 0

Interseção \(=2\) pixels, união \(=6\) pixels (\(4+4-2\)), logo \(\text{IoU}=2/6\approx0{,}3333\) e \(\text{Dice}=2\cdot2/(4+4)=0{,}5000\) — repare que o Dice é sempre igual ou maior que o IoU para a mesma sobreposição.

9.10.5.6 📌 Exemplos

Entrada Saída Observação
4 4
0 0 0 0
0 1 1 0
0 1 1 0
0 0 0 0
0 0 0 0
0 0 1 1
0 0 1 1
0 0 0 0
IoU: 0.3333
Dice: 0.5000
Máscaras \(4\times4\) com sobreposição parcial de 2 pixels.
🎮 Simulador: IoU e Dice Pixel a Pixel 🟠 Segmentação
Interseção (TP) Só predita (FP) Só referência (FN) Fundo (TN)
5×5
Quadrado
🔵 Máscara Predita
🟡 Máscara de Referência
🎯 Comparação Visual
📊 Cálculos e Fórmulas
Figura 9.47: Simulador EP09_05: Avaliação de Segmentação — IoU e Dice Pixel a Pixel
%%writefile EP09_05.py
# Código Python
Writing EP09_05.py
TestSuite("EP09_05.py").run()
✔️ EP09_05.cases já existe em casos/
📋 4 caso(s) carregado(s) de casos/EP09_05.cases

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

9.10.6 EP09_06 🔴 Pipeline Integrado: Da Detecção à Medição do Mundo Real

Este exercício final integra os dois exercícios de detecção e o princípio de fotogrametria apresentado na seção “Fotogrametria e Referência de Escala” — exatamente o mesmo cálculo implementado na figura de medição por referência de escala deste capítulo. O cenário reproduz uma situação realista: um detector (Faster R-CNN ou YOLO) gera várias caixas candidatas sobrepostas para o mesmo objeto de interesse; após filtrá-las por NMS, a caixa sobrevivente de maior confiança é usada, junto de uma caixa de referência de largura real conhecida (como o cartão de \(8{,}56\) cm), para estimar as dimensões reais do objeto detectado.

9.10.6.1 📋 Diretrizes de Implementação

  1. Referência conhecida: Ler o valor real \(L_{ref}\) (largura real do objeto de referência, em cm) e, em seguida, os quatro reais \(x_1\ y_1\ x_2\ y_2\) de sua caixa delimitadora em pixels (já conhecida, sem necessidade de detecção).
  2. Candidatas do objeto a medir: Ler o inteiro \(N\) (número de caixas candidatas produzidas pelo detector para o objeto de interesse) e o limiar real \(\tau\); em seguida, ler as \(N\) linhas de caixas candidatas, cada uma com \(x_1\ y_1\ x_2\ y_2\ \text{score}\).
  3. Etapa 1 — NMS: Aplique exatamente o algoritmo de Supressão de Não-Máximos do EP09_04 às \(N\) caixas candidatas, usando o limiar \(\tau\), para eliminar detecções redundantes do mesmo objeto.
  4. Etapa 2 — Seleção da caixa final: Após o NMS, a caixa de maior score entre as mantidas é a detecção final do objeto (a entrada garante que todas as caixas candidatas correspondem a um único objeto físico, portanto a primeira caixa selecionada pelo NMS já é o resultado final).
  5. Etapa 3 — Medição por referência de escala: Calcule a razão \(\text{cm/pixel} = L_{ref} / \text{largura da referência em pixels}\) e aplique-a tanto à largura quanto à altura (em pixels) da caixa final do objeto, obtendo suas dimensões reais estimadas em centímetros.
  6. Saída: Primeiro, uma linha por caixa mantida após o NMS (mesmo formato do EP09_04): índice score. Em seguida, a linha Total mantidas: X. Por fim, a linha Objeto: L x A cm, onde \(L\) e \(A\) são a largura e a altura estimadas do objeto, cada uma com 2 casas decimais.

9.10.6.2 📌 Restrições Computacionais

  • Reaproveite o NMS do EP09_04 integralmente — mesma regra de desempate, mesmo critério de supressão (\(\text{IoU} > \tau\)).
  • A referência não passa por NMS: sua caixa é dada diretamente, sem candidatas concorrentes.
  • Razão única para largura e altura: assim como na figura de fotogrametria do capítulo, a mesma razão cm/pixel (derivada da largura da referência) é aplicada tanto à largura quanto à altura do objeto — não há calibração vertical separada.

9.10.6.3 🧠 Fundamentação Teórica

Etapa Conceito do capítulo
Múltiplas caixas candidatas Saída bruta de um detector como o Faster R-CNN ou o YOLO, antes do pós-processamento
NMS (EP09_04) Filtra as detecções redundantes, preservando apenas a mais confiável para o objeto
Referência de escala conhecida Mesmo princípio do cartão de \(8{,}56\) cm usado na seção “Fotogrametria e Referência de Escala”
Conversão pixel → centímetro Regra de três simples: \(\text{cm/pixel} = L_{ref} / w_{ref\_px}\), aplicada à caixa final do objeto

9.10.6.4 🧩 Métodos do morph.py que podem ajudar

  • mm.IoU(boxA, boxB) — a mesma função sugerida no EP09_04, aqui reaproveitada dentro da etapa de NMS deste pipeline integrado (lembre-se da conversão de formato: \(w = x_2-x_1\), \(h = y_2-y_1\)).
  • Se você já resolveu o EP09_04 encapsulando a NMS em uma função própria, este é o momento ideal de reaproveitar esse código — a integração de módulos já testados individualmente é exatamente a prática de engenharia que este exercício quer reforçar.

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

Entrada:

  • Linha 1: Real \(L_{ref}\).
  • Linha 2: \(x_1\ y_1\ x_2\ y_2\) da caixa de referência.
  • Linha 3: Inteiro \(N\) e real \(\tau\).
  • Próximas \(N\) linhas: \(x_1\ y_1\ x_2\ y_2\ \text{score}\) das caixas candidatas do objeto.

Saída:

  • Uma linha por caixa mantida após o NMS: índice score.
  • Linha seguinte: Total mantidas: X.
  • Última linha: Objeto: L x A cm.

9.10.6.6 📌 Exemplos

Entrada Saída Observação
8.56
30 200 170 288
3 0.5
250 100 470 250 0.92
255 105 468 245 0.88
600 600 650 650 0.40
0 0.9200
2 0.4000
Total mantidas: 2
Objeto: 13.45 x 9.17 cm
A caixa 1 é suprimida por sobrepor fortemente a caixa 0; a detecção final do objeto é a caixa 0.
🎮 Simulador: Pipeline Integrado — Detecção à Medição 🔴 Fotogrametria
Caixa selecionada Caixa suprimida Referência Objeto final
8.56
0.50
Padrão
🎯 Visualização do Pipeline
📋 Pipeline Passo a Passo
Figura 9.48: Simulador EP09_06: Pipeline Integrado — Detecção à Medição do Mundo Real
%%writefile EP09_06.py
# Código Python
Writing EP09_06.py
TestSuite("EP09_06.py").run()
✔️ EP09_06.cases já existe em casos/
📋 3 caso(s) carregado(s) de casos/EP09_06.cases

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