Visão
Como uma máquina extrai informação de uma imagem.
Visão computacional
Reconhecer um gato em uma foto é, para um humano, uma operação instantânea e inconsciente. É também o problema que mais tempo resistiu à computação. Durante décadas, pesquisadores escreveram à mão detectores de contornos, de cantos, de texturas, e empilharam regras para combiná-los — sem nunca se aproximar da robustez de uma criança de três anos.
A dificuldade não é a falta de informação: é o seu excesso. Uma fotografia de celular contém vários milhões de números, dos quais nenhum significa algo isoladamente. O valor de um pixel não diz nada; é a organização local de milhares de pixels que forma um contorno, e a organização de milhares de contornos que forma um rosto. E essa organização precisa ser reconhecida independentemente da posição do objeto, da iluminação, da escala ou da orientação.
A ideia que destravou o problema é surpreendentemente econômica. Em vez de aprender uma regra para cada posição possível na imagem, aprende-se um pequeno detector e ele é deslocado por toda parte. Um padrão aprendido uma vez é reconhecido onde quer que esteja, e o número de parâmetros a ajustar despenca. Essa operação se chama convolução, e vamos escrevê-la nós mesmos em uma dezena de linhas.
Em seguida, veremos como esses detectores são empilhados, em que as três grandes tarefas da visão realmente diferem, e por que quase ninguém treina hoje um modelo de visão do zero.
Uma imagem é um array de números
Uma imagem em tons de cinza é uma matriz: um valor de intensidade por pixel. Uma imagem colorida são três matrizes sobrepostas, uma para cada canal vermelho, verde e azul.
from sklearn.datasets import load_digits
chiffres = load_digits()
print(chiffres.images.shape) # (1797, 8, 8)
print(chiffres.images.min(), chiffres.images.max()) # 0.0 16.0
print(chiffres.target[0]) # 0
print(chiffres.images[0].astype(int))
# [[ 0 0 5 13 9 1 0 0]
# [ 0 0 13 15 10 15 5 0]
# [ 0 3 15 2 0 11 8 0]
# [ 0 4 12 0 0 8 8 0]
# [ 0 5 8 0 0 9 8 0]
# [ 0 4 11 0 1 12 7 0]
# [ 0 2 14 5 10 12 0 0]
# [ 0 0 6 13 10 0 0 0]]
Franzindo os olhos, dá para adivinhar o zero: duas colunas de valores altos separadas por um vazio central de zeros. É exatamente isso que a máquina recebe — nem mais, nem menos.
A escala é o primeiro obstáculo. Este conjunto de dados usa miniaturas de 8 × 8, ou seja, 64 números por imagem. Uma fotografia de entrada comum para uma rede de visão tem 224 × 224 pixels em 3 canais:
print(224 * 224 * 3) # 150528
150.528 números para uma única imagem. Todo o resto consiste em tratar esse volume sem fazer o número de parâmetros a aprender explodir.
A convolução: detectar um padrão, onde quer que ele esteja
Uma convolução desliza uma pequena matriz de pesos — o kernel, ou filtro — sobre a imagem. Em cada posição, ela multiplica termo a termo e soma. O resultado é uma nova imagem, chamada mapa de ativação, que indica onde o padrão procurado está presente.
import numpy as np
# Uma barra vertical clara sobre fundo escuro.
image = np.array([
[0, 0, 9, 9, 0, 0],
[0, 0, 9, 9, 0, 0],
[0, 0, 9, 9, 0, 0],
[0, 0, 9, 9, 0, 0],
], dtype=float)
# Kernel de Sobel simplificado: detector de contorno vertical.
noyau = np.array([[1.0, 0.0, -1.0],
[1.0, 0.0, -1.0],
[1.0, 0.0, -1.0]])
def convoluer(image, noyau):
h, w = image.shape
kh, kw = noyau.shape
sortie = np.zeros((h - kh + 1, w - kw + 1))
for y in range(sortie.shape[0]):
for x in range(sortie.shape[1]):
fenetre = image[y:y + kh, x:x + kw]
sortie[y, x] = float((fenetre * noyau).sum())
return sortie
carte = convoluer(image, noyau)
print(carte)
# [[-27. -27. 27. 27.]
# [-27. -27. 27. 27.]]
Vamos verificar um valor à mão. No canto superior direito, a janela cobre as colunas 3, 4, 5, ou seja, os valores [[9,0,0],[9,0,0],[9,0,0]]. O produto com o kernel dá 9×1 + 0×0 + 0×(−1) repetido em três linhas, ou seja, 27. O valor +27 sinaliza uma transição claro → escuro. À esquerda, a mesma transição no sentido inverso dá −27. No centro do padrão, um kernel simétrico daria 0: nada a sinalizar.
Três propriedades fazem todo o poder da operação:
- Localidade. Cada saída depende apenas de uma pequena vizinhança, o que corresponde à estrutura real das imagens: um contorno é um fenômeno local.
- Compartilhamento de pesos. Os nove pesos do kernel são os mesmos em todas as posições. Um detector aprendido uma vez funciona em qualquer lugar da imagem.
- Economia. É a consequência numérica do ponto anterior, e é espetacular.
# Uma camada densa ligando todos os pixels de uma imagem 224×224×3 a 1000 neurônios:
print(150528 * 1000 + 1000) # 150529000
# Uma camada convolucional de 64 kernels de 3×3 sobre 3 canais:
print(3 * 3 * 3 * 64 + 64) # 1792
150 milhões de parâmetros contra 1.792. Um fator de aproximadamente 84.000. Sem o compartilhamento de pesos, o aprendizado profundo sobre imagens nunca teria sido viável, e isso não é questão de paciência: o número de exemplos necessários para ajustar 150 milhões de parâmetros em uma única camada está fora de alcance.
Reduzir, empilhar e enxergar mais amplamente
Uma convolução isolada só olha para uma vizinhança de 3 × 3. Para reconhecer um objeto inteiro, é preciso ampliar progressivamente o campo de visão. Dois mecanismos cuidam disso.
O primeiro é a subamostragem (pooling), cuja forma mais comum conserva o máximo de cada bloco:
def pooling_max(carte, taille=2):
h, w = carte.shape
return carte[:h - h % taille, :w - w % taille].reshape(
h // taille, taille, w // taille, taille).max(axis=(1, 3))
print(pooling_max(np.abs(carte))) # [[27. 27.]]
O mapa de 2 × 4 passa a 1 × 2. Perde-se a posição exata do contorno e mantém-se sua intensidade: é precisamente o compromisso desejado para a classificação, em que saber que existe um contorno importa mais do que sua posição ao pixel.
O segundo mecanismo é o empilhamento. Cada camada vê as saídas da anterior, portanto uma vizinhança cada vez mais ampla da imagem original:
Camada 1 (3×3 pixels vistos) → contornos, manchas de cor
Camada 2 (~7×7 pixels vistos) → cantos, ângulos, curvas
Camada 3 (~15×15) → padrões: olho, roda, textura
Camada 4+ (grande parte) → partes de objetos, depois objetos
Essa hierarquia não é imposta pelo programador: ela emerge do treinamento, e foi reencontrada experimentalmente em redes treinadas, visualizando o que mais ativa cada filtro. É o aprendizado de representações visto na lição anterior, aplicado às imagens.
Duas datas estruturam essa história: a LeNet-5, publicada por Yann LeCun et al. em 1998, já aplica esse esquema à leitura de cheques; a AlexNet, em 2012, leva-o à escala da ImageNet e desencadeia a onda atual.
Classificar, detectar, segmentar
Essas três palavras designam tarefas frequentemente confundidas. Elas se distinguem sem ambiguidade pela forma de sua saída, o que determina o custo da anotação e a dificuldade do problema.
| Tarefa | Pergunta | Saída para uma imagem 224×224 e 20 classes |
|---|---|---|
| Classificação | o que há nesta imagem? | 20 números (uma probabilidade por classe) |
| Detecção | onde estão os objetos e quais são? | uma lista: classe, score e 4 coordenadas por objeto |
| Segmentação | a qual classe pertence cada pixel? | 224 × 224 × 20 = 1.003.520 números |
print(224 * 224 * 20) # 1003520
A diferença entre 20 e um milhão de valores de saída explica o essencial: a segmentação exige anotações ao nível do pixel, cujo custo de produção não tem comparação com um simples rótulo de imagem.
Na detecção, a métrica de referência mede a sobreposição entre a caixa prevista e a caixa de referência. Ela é calculada exatamente:
def iou(a, b):
"""Interseção sobre união de duas caixas (x1, y1, x2, y2)."""
x1 = max(a[0], b[0]); y1 = max(a[1], b[1])
x2 = min(a[2], b[2]); y2 = min(a[3], b[3])
inter = max(0, x2 - x1) * max(0, y2 - y1)
aire_a = (a[2] - a[0]) * (a[3] - a[1])
aire_b = (b[2] - b[0]) * (b[3] - b[1])
return inter / (aire_a + aire_b - inter)
print(round(iou((10, 10, 50, 50), (30, 30, 70, 70)), 3)) # 0.143
print(round(iou((10, 10, 50, 50), (12, 12, 52, 52)), 3)) # 0.822
Primeiro caso: duas caixas de 40 × 40 deslocadas em 20 pixels se sobrepõem em 20 × 20 = 400 pixels, para uma união de 1600 + 1600 − 400 = 2800, ou seja, 400 / 2800 ≈ 0,143. A convenção usual considera uma detecção correta acima de 0,5. A segunda caixa, deslocada em apenas 2 pixels, se sobrepõe em 38 × 38 = 1.444 pixels para uma união de 1.756, ou seja, 0,822: correta.
O transfer learning
Treinar uma rede de visão do zero exige muitas imagens anotadas — a ImageNet fornece cerca de 1,2 milhão para 1000 categorias — e um orçamento de computação considerável. Quase ninguém dispõe disso para um problema específico de negócio: classificar peças defeituosas, triar radiografias, reconhecer uma espécie vegetal.
O transfer learning resolve esse problema em duas etapas. Reaproveita-se uma rede já treinada em um grande corpus, congelam-se suas camadas — elas aprenderam detectores de contornos, de texturas e de padrões que continuam relevantes para outras imagens — e reaprende-se apenas a última camada, a que decide.
A economia é quantificável. Com um backbone produzindo um vetor de 2048 características e 10 classes-alvo, a camada a aprender tem 2048 × 10 + 10 = 20.490 parâmetros, em vez das dezenas de milhões da rede completa.
Resta saber se o princípio se verifica. Vamos testá-lo honestamente: “pré-treinamos” uma representação nos dígitos de 5 a 9, depois aprendemos a reconhecer os dígitos de 0 a 4 com apenas 5 exemplos por classe.
import numpy as np
from sklearn.datasets import load_digits
from sklearn.decomposition import PCA
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
X, y = load_digits(return_X_y=True)
source = X[y >= 5] # « pré-treinamento »: classes 5 a 9
cible_X, cible_y = X[y < 5], y[y < 5] # tarefa-alvo: classes 0 a 4
Xp, Xt, yp, yt = train_test_split(cible_X, cible_y, test_size=0.4,
random_state=0, stratify=cible_y)
indices = np.concatenate([np.where(yp == c)[0][:5] for c in range(5)])
brut = LogisticRegression(max_iter=5000).fit(Xp[indices], yp[indices])
print(round(brut.score(Xt, yt), 3)) # 0.936
# A representação é aprendida apenas nas classes 5-9,
# sem jamais ver um rótulo da tarefa-alvo.
pca = PCA(n_components=30, random_state=0).fit(source)
tete = LogisticRegression(max_iter=5000).fit(pca.transform(Xp[indices]), yp[indices])
print(round(tete.score(pca.transform(Xt), yt), 3)) # 0.931
O resultado merece um comentário franco: a representação transferida não faz melhor que os pixels brutos (0,931 contra 0,936). O experimento não demonstra o benefício do transfer learning, e seria desonesto apresentá-lo como se demonstrasse.
Esse resultado negativo é instrutivo. O transfer learning não traz nada aqui por três razões identificáveis: uma miniatura de 8 × 8 contém apenas 64 valores, já aproveitáveis diretamente; a análise de componentes principais é uma transformação linear, incapaz de construir a hierarquia de padrões que dá valor a um backbone convolucional; e 30 componentes em 64 dimensões quase não comprimem nada. O transfer learning produz seus ganhos documentados quando o backbone é profundo, não linear e pré-treinado em imagens muito mais ricas que essas miniaturas. A mecânica mostrada acima — congelar a representação, aprender apenas a cabeça — é a que se emprega na prática; a escala, por sua vez, não é a que a torna rentável.
Pontos-chave
- Uma imagem é um array de números, e o problema é a quantidade deles: 150.528 valores para uma única fotografia colorida de 224 × 224.
- A convolução desloca um pequeno kernel de pesos compartilhados por toda a imagem. É esse compartilhamento que torna a coisa viável: 1.792 parâmetros em vez de 150 milhões para uma camada densa equivalente.
- Subamostrar e empilhar camadas amplia progressivamente o campo de visão e faz emergir uma hierarquia — contornos, padrões, partes de objetos — que ninguém especificou.
- Classificação, detecção e segmentação diferem pela forma de sua saída, de 20 números a mais de um milhão. O custo da anotação segue a mesma progressão.
- O transfer learning consiste em congelar um backbone pré-treinado e reaprender apenas a camada de decisão, ou seja, cerca de 20.000 parâmetros em vez de milhões. Seu benefício depende da riqueza do backbone: em miniaturas 8 × 8 com uma transformação linear, o experimento acima não mostra nenhum.
Comentários
Os comentários são alimentados pelo GitHub Discussions
Conecte-se com o GitHub para participar da discussão