0%
Visão computacional

Visão

Como uma máquina extrai informação de uma imagem.

10-15 min

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.

TarefaPerguntaSaída para uma imagem 224×224 e 20 classes
Classificaçãoo que há nesta imagem?20 números (uma probabilidade por classe)
Detecçãoonde estão os objetos e quais são?uma lista: classe, score e 4 coordenadas por objeto
Segmentaçãoa 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

Link copiado!