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

  1. (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.
  2. (Médio) Escreva standardize(img, mean, std) e seu inverso que juntos passem em um teste de roundtrip_max_diff <= 1 em qualquer imagem uint8. Suas funções devem funcionar em uma única imagem em HWC e em um batch em NCHW com a mesma chamada.
  3. (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 seu rgb_to_grayscale manual 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

0 lifetime access. Curriculum based on AI Engineering from Scratch by Rohit Ghumare (MIT, used under attribution).