Phase 04 - Lesson 01
Fundamentos de Imagem — Pixels, Canais, Espaços de Cor
This lesson includes a graded coding exercise that runs in your browser, unlocked with lifetime access.
Uma imagem é um tensor de amostras de luz. Todo modelo de visão que você um dia usar parte desse único fato.
Tipo: Build Linguagens: Python Pré-requisitos: Fase 1 Lição 12 (Operações com Tensores), Fase 3 Lição 11 (Introdução ao PyTorch) Tempo: ~45 minutos
Objetivos de Aprendizagem
- Explicar como uma cena contínua é discretizada em pixels e por que as decisões de amostragem/quantização definem o teto de todo modelo a jusante
- Ler, fatiar e inspecionar imagens como arrays NumPy e alternar fluentemente entre os layouts HWC e CHW
- Converter entre RGB, escala de cinza, HSV e YCbCr e justificar por que cada espaço de cor existe
- Aplicar pré-processamento em nível de pixel (normalizar, padronizar, redimensionar, channel-first) exatamente como o torchvision espera
O Problema
Todo artigo que você vai ler, todo peso pré-treinado que você vai baixar, toda API de visão que você vai chamar assume uma codificação específica da entrada. Passe uma imagem uint8 onde o modelo quer float32 e ele ainda vai rodar — e silenciosamente produzir lixo. Alimente uma rede treinada em RGB com BGR e a acurácia desaba dez pontos. Entregue a um modelo uma entrada channels-last quando ele espera channels-first e a primeira camada conv trata a altura como um canal de features. Nada disso lança erro. Apenas arruína suas métricas e você passa uma semana caçando um bug que mora em como você carregou o arquivo.
Uma convolução não é complicada uma vez que você sabe sobre o que ela desliza. A parte difícil é que "uma imagem" significa coisas diferentes para uma câmera, um decodificador JPEG, o PIL, o OpenCV, o torchvision e um kernel CUDA. Cada stack tem sua própria ordem de eixos, faixa de bytes e convenção de canais. Um engenheiro de visão que não consegue manter isso organizado entrega pipelines quebrados.
Esta lição corrige a fundação para que o resto da fase possa construir sobre ela. Ao final você saberá o que é um pixel, por que há três números por pixel em vez de um, o que "normalizar com estatísticas da ImageNet" realmente faz e como transitar entre os dois ou três layouts que toda outra lição desta fase vai assumir.
O Conceito
O pipeline completo de pré-processamento em um relance
Todo sistema de visão em produção é a mesma sequência de transformações reversíveis. Erre um passo e o modelo verá uma entrada diferente daquela com que foi treinado.
flowchart LR
A["Arquivo de imagem<br/>(JPEG/PNG)"] --> B["Decodificar<br/>uint8 HWC"]
B --> C["Converter<br/>espaço de cor<br/>(RGB/BGR/YCbCr)"]
C --> D["Redimensionar<br/>lado menor"]
D --> E["Center crop<br/>tamanho do modelo"]
E --> F["Dividir por 255<br/>float32 [0,1]"]
F --> G["Subtrair média<br/>Dividir por desvio"]
G --> H["Transpor<br/>HWC → CHW"]
H --> I["Batch<br/>CHW → NCHW"]
I --> J["Modelo"]
style A fill:#fef3c7,stroke:#d97706
style J fill:#ddd6fe,stroke:#7c3aed
style G fill:#fecaca,stroke:#dc2626
style H fill:#bfdbfe,stroke:#2563eb
As duas caixas vermelha e azul são onde moram 80% das falhas silenciosas: padronização ausente e layout errado.
Um pixel é uma amostra, não um quadrado
Um sensor de câmera conta fótons que pousam em uma grade de minúsculos detectores. Cada detector integra luz por uma fração de segundo e emite uma voltagem proporcional a quantos fótons o atingiram. O sensor então discretiza essa voltagem em um inteiro. Um detector vira um pixel.
Continuous scene Sensor grid Digital image
(infinite detail) (H x W detectors) (H x W integers)
~~~~~ +--+--+--+--+--+ 210 198 180 155 120
~ ~ ~ | | | | | | 205 195 178 152 118
~ light ~ ----> +--+--+--+--+--+ ----> 200 190 175 150 115
~~~~~ | | | | | | 195 185 170 148 112
+--+--+--+--+--+ 188 180 165 145 108
Duas escolhas acontecem nesse passo e elas fixam o teto de tudo a jusante:
- A amostragem espacial decide quantos detectores por grau da cena. Poucos demais, e as bordas ficam serrilhadas (aliasing). Muitos demais, e o armazenamento e a computação explodem.
- A quantização de intensidade decide com que precisão a voltagem é dividida em níveis. 8 bits dão 256 níveis e são o padrão para exibição. 10, 12, 16 bits dão gradientes mais suaves e importam para imagem médica, HDR e pipelines de sensor raw.
Um pixel não é um quadrado colorido com área. É uma única medição. Quando você redimensiona ou rotaciona, está reamostrando essa grade de medições.
Por que três canais
Um detector conta fótons em todo o espectro visível — isso é escala de cinza. Para obter cor, o sensor cobre a grade com um mosaico de filtros vermelho, verde e azul. Após o demosaicing, cada posição espacial tem três inteiros: a resposta do detector filtrado em vermelho, em verde e em azul nas proximidades. Esses três inteiros são o trio RGB de um pixel.
One pixel in memory:
(R, G, B) = (210, 140, 30) <- reddish-orange
An H x W RGB image:
shape (H, W, 3) stored as H rows of W pixels of 3 values
each in [0, 255] for uint8
Três não é mágico. Câmeras de profundidade adicionam um canal Z. Satélites adicionam bandas de infravermelho e ultravioleta. Exames médicos costumam ter um canal (raio-X, TC) ou muitos (hiperespectral). O número de canais é o último eixo; as camadas conv aprendem a misturar ao longo dele.
Duas convenções de layout: HWC e CHW
Mesmo tensor, duas ordenações. Toda biblioteca escolhe uma.
HWC (height, width, channels) CHW (channels, height, width)
W -> H ->
+-----+-----+-----+ +-----+-----+
H |R G B|R G B|R G B| C |R R R R R R|
| +-----+-----+-----+ | +-----+-----+
v |R G B|R G B|R G B| v |G G G G G G|
+-----+-----+-----+ +-----+-----+
|B B B B B B|
+-----+-----+
PIL, OpenCV, matplotlib, PyTorch, most deep learning
almost every image file on disk frameworks, cuDNN kernels
CHW existe porque os kernels de convolução deslizam ao longo de H e W. Manter o eixo de canais primeiro significa que cada kernel vê um plano 2D contíguo por canal, o que vetoriza de forma limpa. Os formatos de disco mantêm HWC porque isso casa com a forma como as linhas de varredura saem de um sensor.
A conversão de uma linha que você vai digitar mil vezes:
img_chw = img_hwc.transpose(2, 0, 1) # NumPy
img_chw = img_hwc.permute(2, 0, 1) # PyTorch tensor
Layout de memória, visualizado:
flowchart TB
subgraph HWC["HWC — pixels armazenados intercalados (PIL, OpenCV, JPEG)"]
H1["linha 0: R G B | R G B | R G B ..."]
H2["linha 1: R G B | R G B | R G B ..."]
H3["linha 2: R G B | R G B | R G B ..."]
end
subgraph CHW["CHW — canais armazenados como planos empilhados (PyTorch, cuDNN)"]
C1["plano R: todo o H x W de valores vermelhos"]
C2["plano G: todo o H x W de valores verdes"]
C3["plano B: todo o H x W de valores azuis"]
end
HWC -->|"transpose(2, 0, 1)"| CHW
CHW -->|"transpose(1, 2, 0)"| HWC
Faixas de bytes e dtype
Três convenções dominam:
| Convenção | dtype | Faixa | Onde você vê |
|---|---|---|---|
| Raw | uint8 |
[0, 255] | Arquivos em disco, PIL, saída do OpenCV |
| Normalizado | float32 |
[0.0, 1.0] | Após img.astype('float32') / 255 |
| Padronizado | float32 |
aproximadamente [-2, +2] | Após subtrair a média e dividir pelo desvio |
Redes convolucionais foram treinadas com entradas padronizadas. As estatísticas da ImageNet mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225] são a média aritmética e o desvio padrão dos três canais sobre todo o conjunto de treino da ImageNet, computados em pixels normalizados em [0, 1]. Alimentar uint8 raw em um modelo que espera float padronizado é a falha silenciosa mais comum em visão aplicada.
Espaços de cor e por que existem
RGB é o formato de captura, mas nem sempre é a representação mais útil para um modelo.
RGB HSV YCbCr / YUV
R red H hue (angle 0-360) Y luminance (brightness)
G green S saturation (0-1) Cb chroma blue-yellow
B blue V value/brightness (0-1) Cr chroma red-green
Linear to Separates color from Separates brightness from
sensor output brightness. Useful for color. JPEG and most video
color thresholding, UI codecs compress the chroma
sliders, simple filters channels harder because the
human eye is less sensitive
to chroma detail than to Y.
Para a maioria das CNNs modernas você alimenta RGB. Você encontra outros espaços quando:
- HSV — código clássico de CV, segmentação baseada em cor, balanço de branco.
- YCbCr — leitura de internos do JPEG, pipelines de vídeo, modelos de super-resolução que operam apenas no Y.
- Escala de cinza — OCR, modelos de documentos, qualquer caso em que a cor seja variável de ruído e não sinal.
Escala de cinza a partir de RGB é uma soma ponderada, não uma média, porque o olho humano é mais sensível ao verde do que ao vermelho ou ao azul:
Y = 0.299 R + 0.587 G + 0.114 B (ITU-R BT.601, the classic weights)
Proporção, redimensionamento e interpolação
Todo modelo tem um tamanho de entrada fixo (224x224 para a maioria dos classificadores da ImageNet, 384x384 ou 512x512 para detectores modernos). Suas imagens raramente coincidem. As três escolhas de redimensionamento que importam:
- Redimensionar o lado menor, depois center crop — a receita padrão da ImageNet. Preserva a proporção, descarta uma faixa de pixels de borda.
- Redimensionar e fazer padding — preserva a proporção e cada pixel, adiciona barras pretas. Padrão para detecção e OCR.
- Redimensionar direto para o alvo — estica a imagem. Barato, distorce a geometria, ok para muitas tarefas de classificação.
O método de interpolação decide como os pixels intermediários são computados quando a nova grade não se alinha à antiga:
Nearest neighbour fastest, blocky, only choice for masks/labels
Bilinear fast, smooth, default for most image resizing
Bicubic slower, sharper on upscaling
Lanczos slowest, best quality, used for final display
Regra prática: bilinear para treino, bicubic ou lanczos para assets que você vai olhar, nearest para qualquer coisa que contenha IDs de classe inteiros.
Construa
Passo 1: Carregue uma imagem e inspecione seu shape
Use o Pillow para carregar qualquer JPEG ou PNG, converta para NumPy e imprima o que você obteve. Para um exemplo determinístico que roda offline, sintetize um.
import numpy as np
from PIL import Image
def synthetic_rgb(h=128, w=192, seed=0):
rng = np.random.default_rng(seed)
yy, xx = np.meshgrid(np.linspace(0, 1, h), np.linspace(0, 1, w), indexing="ij")
r = (np.sin(xx * 6) * 0.5 + 0.5) * 255
g = yy * 255
b = (1 - yy) * xx * 255
rgb = np.stack([r, g, b], axis=-1) + rng.normal(0, 6, (h, w, 3))
return np.clip(rgb, 0, 255).astype(np.uint8)
arr = synthetic_rgb()
# Or load from disk:
# arr = np.asarray(Image.open("your_image.jpg").convert("RGB"))
print(f"type: {type(arr).__name__}")
print(f"dtype: {arr.dtype}")
print(f"shape: {arr.shape} # (H, W, C)")
print(f"min: {arr.min()}")
print(f"max: {arr.max()}")
print(f"pixel at (0, 0): {arr[0, 0]}")
Saída esperada: shape: (H, W, 3), dtype: uint8, faixa [0, 255]. Essa é a representação canônica em disco, quer os bytes tenham vindo de uma câmera, de um decodificador JPEG ou de um gerador sintético.
Passo 2: Separe os canais e reordene o layout
Extraia R, G, B separadamente, depois converta de HWC para CHW para o PyTorch.
R = arr[:, :, 0]
G = arr[:, :, 1]
B = arr[:, :, 2]
print(f"R shape: {R.shape}, mean: {R.mean():.1f}")
print(f"G shape: {G.shape}, mean: {G.mean():.1f}")
print(f"B shape: {B.shape}, mean: {B.mean():.1f}")
arr_chw = arr.transpose(2, 0, 1)
print(f"\nHWC shape: {arr.shape}")
print(f"CHW shape: {arr_chw.shape}")
Três planos em escala de cinza, um por canal. CHW apenas reordena os eixos; nenhuma cópia de dados é estritamente necessária quando o layout de memória permite.
Passo 3: Conversões para escala de cinza e HSV
Escala de cinza por soma ponderada, depois um RGB-para-HSV manual.
def rgb_to_grayscale(rgb):
weights = np.array([0.299, 0.587, 0.114], dtype=np.float32)
return (rgb.astype(np.float32) @ weights).astype(np.uint8)
def rgb_to_hsv(rgb):
rgb_f = rgb.astype(np.float32) / 255.0
r, g, b = rgb_f[..., 0], rgb_f[..., 1], rgb_f[..., 2]
cmax = np.max(rgb_f, axis=-1)
cmin = np.min(rgb_f, axis=-1)
delta = cmax - cmin
h = np.zeros_like(cmax)
mask = delta > 0
rmax = mask & (cmax == r)
gmax = mask & (cmax == g)
bmax = mask & (cmax == b)
h[rmax] = ((g[rmax] - b[rmax]) / delta[rmax]) % 6
h[gmax] = ((b[gmax] - r[gmax]) / delta[gmax]) + 2
h[bmax] = ((r[bmax] - g[bmax]) / delta[bmax]) + 4
h = h * 60.0
s = np.where(cmax > 0, delta / cmax, 0)
v = cmax
return np.stack([h, s, v], axis=-1)
gray = rgb_to_grayscale(arr)
hsv = rgb_to_hsv(arr)
print(f"gray shape: {gray.shape}, range: [{gray.min()}, {gray.max()}]")
print(f"hsv shape: {hsv.shape}")
print(f"hue range: [{hsv[..., 0].min():.1f}, {hsv[..., 0].max():.1f}] degrees")
print(f"sat range: [{hsv[..., 1].min():.2f}, {hsv[..., 1].max():.2f}]")
print(f"val range: [{hsv[..., 2].min():.2f}, {hsv[..., 2].max():.2f}]")
O matiz (hue) sai em graus, saturação e valor em [0, 1]. Isso casa com a convenção hsv_full do OpenCV.
Passo 4: Normalize, padronize e reverta
Vá dos bytes raw ao tensor exato que um modelo ImageNet pré-treinado espera, e de volta.
mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
std = np.array([0.229, 0.224, 0.225], dtype=np.float32)
def preprocess_imagenet(rgb_uint8):
x = rgb_uint8.astype(np.float32) / 255.0
x = (x - mean) / std
x = x.transpose(2, 0, 1)
return x
def deprocess_imagenet(chw_float32):
x = chw_float32.transpose(1, 2, 0)
x = x * std + mean
x = np.clip(x * 255.0, 0, 255).astype(np.uint8)
return x
x = preprocess_imagenet(arr)
print(f"preprocessed shape: {x.shape} # (C, H, W)")
print(f"preprocessed dtype: {x.dtype}")
print(f"preprocessed mean per channel: {x.mean(axis=(1, 2)).round(3)}")
print(f"preprocessed std per channel: {x.std(axis=(1, 2)).round(3)}")
roundtrip = deprocess_imagenet(x)
max_diff = np.abs(roundtrip.astype(int) - arr.astype(int)).max()
print(f"roundtrip max pixel diff: {max_diff} # should be 0 or 1")
A média por canal deve ficar próxima de zero, o desvio próximo de um. O par preprocess/deprocess é exatamente o que toda chamada transforms.Normalize do torchvision faz por baixo dos panos.
Passo 5: Redimensione com três métodos de interpolação
Compare nearest, bilinear e bicubic em um upscale para que a diferença fique visível.
target = (arr.shape[0] * 3, arr.shape[1] * 3)
nearest = np.asarray(Image.fromarray(arr).resize(target[::-1], Image.NEAREST))
bilinear = np.asarray(Image.fromarray(arr).resize(target[::-1], Image.BILINEAR))
bicubic = np.asarray(Image.fromarray(arr).resize(target[::-1], Image.BICUBIC))
def local_roughness(x):
gy = np.diff(x.astype(float), axis=0)
gx = np.diff(x.astype(float), axis=1)
return float(np.abs(gy).mean() + np.abs(gx).mean())
for name, out in [("nearest", nearest), ("bilinear", bilinear), ("bicubic", bicubic)]:
print(f"{name:>8} shape={out.shape} roughness={local_roughness(out):6.2f}")
Nearest pontua mais alto em rugosidade porque mantém bordas duras. Bilinear é o mais suave. Bicubic fica no meio, preservando a nitidez percebida sem os artefatos de degrau.
Use
torchvision.transforms agrupa tudo acima em um único pipeline componível. O código abaixo reproduz exatamente o que preprocess_imagenet faz, mais resize e crop.
import torch
from torchvision import transforms
from PIL import Image
img = Image.fromarray(synthetic_rgb(256, 256))
pipeline = transforms.Compose([
transforms.Resize(256),
transforms.CenterCrop(224),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
])
x = pipeline(img)
print(f"tensor type: {type(x).__name__}")
print(f"tensor dtype: {x.dtype}")
print(f"tensor shape: {tuple(x.shape)} # (C, H, W)")
print(f"per-channel mean: {x.mean(dim=(1, 2)).tolist()}")
print(f"per-channel std: {x.std(dim=(1, 2)).tolist()}")
batch = x.unsqueeze(0)
print(f"\nbatched shape: {tuple(batch.shape)} # (N, C, H, W) — ready for a model")
Quatro passos, exatamente nesta ordem: Resize(256) escala o lado menor para 256; CenterCrop(224) pega um patch 224x224 do meio; ToTensor() divide por 255 e troca HWC por CHW; Normalize subtrai a média da ImageNet e divide pelo desvio. Inverter essa ordem muda silenciosamente o que chega ao modelo.
Entregue
Esta lição produz:
outputs/prompt-vision-preprocessing-audit.md— um prompt que transforma qualquer model card ou dataset card em uma checklist dos invariantes exatos de pré-processamento que uma equipe deve honrar.outputs/skill-image-tensor-inspector.md— uma skill que, dado qualquer tensor ou array em formato de imagem, reporta dtype, layout, faixa e se ele parece raw, normalizado ou padronizado.
Exercícios
- (Fácil) Carregue um JPEG com o OpenCV (
cv2.imread) e com o Pillow. Imprima ambos os shapes e o pixel em(0, 0). Explique a diferença de ordem de canais, depois escreva uma conversão de uma linha que torna o array do OpenCV idêntico ao do Pillow. - (Médio) Escreva
standardize(img, mean, std)e seu inverso que juntos passem em um teste deroundtrip_max_diff <= 1em qualquer imagem uint8. Suas funções devem funcionar em uma única imagem em HWC e em um batch em NCHW com a mesma chamada. - (Difícil) Pegue um tensor de 3 canais padronizado pela ImageNet e passe-o por uma conv 1x1 que aprende uma mistura ponderada de RGB em um único canal de escala de cinza. Inicialize os pesos com
[0.299, 0.587, 0.114], congele-os e verifique se a saída casa com seurgb_to_grayscalemanual dentro do erro de ponto flutuante. Que outras transformações clássicas de espaço de cor podem ser escritas como convoluções 1x1?
Termos-chave
| Termo | O que as pessoas dizem | O que realmente significa |
|---|---|---|
| Pixel | "Um quadrado colorido" | Uma amostra de intensidade de luz em uma posição da grade — três números para cor, um para escala de cinza |
| Canal | "A cor" | Uma das grades espaciais paralelas empilhadas em um tensor de imagem; último eixo em HWC, primeiro em CHW |
| HWC / CHW | "O shape" | Ordenações de eixos para um tensor de imagem; disco e PIL usam HWC, PyTorch e cuDNN usam CHW |
| Normalizar | "Escalar a imagem" | Dividir por 255 para os pixels viverem em [0, 1] — necessário mas não suficiente |
| Padronizar | "Centralizar em zero" | Subtrair a média e dividir pelo desvio por canal para que a distribuição de entrada case com aquela em que o modelo foi treinado |
| Conversão para escala de cinza | "Tirar a média dos canais" | Uma soma ponderada com coeficientes 0.299/0.587/0.114 que casa com a percepção humana de luminância |
| Interpolação | "Como o resize escolhe pixels" | A regra que decide os valores de saída quando a nova grade não se alinha à antiga — nearest para labels, bilinear para treino, bicubic para exibição |
| Proporção (aspect ratio) | "Largura sobre altura" | A razão que distingue "redimensionar e fazer padding" de "redimensionar e esticar" |
Leitura Adicional
- Charles Poynton — A Guided Tour of Color Space — o tratamento técnico mais claro de por que há tantos espaços de cor e quando cada um importa
- PyTorch Vision Transforms Docs — o pipeline completo de transformações que você de fato vai compor em produção
- How JPEG Works (Colt McAnlis) — um tour visual afiado sobre subamostragem de croma, DCT e por que o JPEG codifica YCbCr em vez de RGB
- ImageNet Preprocessing Conventions (torchvision models) — a fonte de verdade para
mean=[0.485, 0.456, 0.406]e por que todo modelo do zoo a espera