Phase 04 - Lesson 01

Fundamentos de Imagen — Píxeles, Canales, Espacios de Color

This lesson includes a graded coding exercise that runs in your browser, unlocked with lifetime access.

Una imagen es un tensor de muestras de luz. Todo modelo de visión que llegues a usar parte de este único hecho.

Tipo: Build Lenguajes: Python Requisitos previos: Fase 1 Lección 12 (Operaciones con Tensores), Fase 3 Lección 11 (Introducción a PyTorch) Tiempo: ~45 minutos

Objetivos de Aprendizaje

  • Explicar cómo una escena continua se discretiza en píxeles y por qué las decisiones de muestreo/cuantización fijan el techo de todo modelo posterior
  • Leer, segmentar e inspeccionar imágenes como arrays de NumPy y alternar con fluidez entre los layouts HWC y CHW
  • Convertir entre RGB, escala de grises, HSV y YCbCr y justificar por qué existe cada espacio de color
  • Aplicar preprocesamiento a nivel de píxel (normalizar, estandarizar, redimensionar, channel-first) exactamente como lo espera torchvision

El Problema

Todo paper que vas a leer, todo peso preentrenado que vas a descargar, toda API de visión que vas a invocar asume una codificación específica de la entrada. Pasa una imagen uint8 donde el modelo quiere float32 y aun así va a correr — y silenciosamente producir basura. Aliméntale BGR a una red entrenada en RGB y la exactitud se desploma diez puntos. Entrégale a un modelo una entrada channels-last cuando espera channels-first y la primera capa conv trata la altura como un canal de features. Nada de esto lanza un error. Solo arruina tus métricas y te pasas una semana cazando un bug que vive en cómo cargaste el archivo.

Una convolución no es complicada una vez que sabes sobre qué se desliza. La parte difícil es que "una imagen" significa cosas distintas para una cámara, un decodificador JPEG, PIL, OpenCV, torchvision y un kernel CUDA. Cada stack tiene su propio orden de ejes, rango de bytes y convención de canales. Un ingeniero de visión que no logra mantener esto en orden entrega pipelines rotos.

Esta lección arregla la base para que el resto de la fase pueda construir sobre ella. Al final sabrás qué es un píxel, por qué hay tres números por píxel en lugar de uno, qué hace en realidad "normalizar con estadísticas de ImageNet", y cómo moverte entre los dos o tres layouts que toda otra lección de esta fase va a asumir.

El Concepto

El pipeline completo de preprocesamiento de un vistazo

Todo sistema de visión en producción es la misma secuencia de transformaciones reversibles. Equivócate en un paso y el modelo verá una entrada distinta de aquella con la que fue entrenado.

flowchart LR
    A["Archivo de imagen<br/>(JPEG/PNG)"] --> B["Decodificar<br/>uint8 HWC"]
    B --> C["Convertir<br/>espacio de color<br/>(RGB/BGR/YCbCr)"]
    C --> D["Redimensionar<br/>lado más corto"]
    D --> E["Center crop<br/>tamaño del modelo"]
    E --> F["Dividir por 255<br/>float32 [0,1]"]
    F --> G["Restar media<br/>Dividir por desviación"]
    G --> H["Transponer<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

Las dos cajas roja y azul son donde vive el 80% de las fallas silenciosas: estandarización faltante y layout equivocado.

Un píxel es una muestra, no un cuadrado

El sensor de una cámara cuenta fotones que aterrizan en una grilla de diminutos detectores. Cada detector integra luz durante una fracción de segundo y emite un voltaje proporcional a cuántos fotones lo golpearon. El sensor luego discretiza ese voltaje en un entero. Un detector se convierte en un píxel.

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

Dos decisiones ocurren en este paso y fijan el techo de todo lo posterior:

  • El muestreo espacial decide cuántos detectores por grado de la escena. Muy pocos, y los bordes quedan dentados (aliasing). Demasiados, y el almacenamiento y el cómputo explotan.
  • La cuantización de intensidad decide con qué finura se agrupa el voltaje en niveles. 8 bits dan 256 niveles y son el estándar para visualización. 10, 12, 16 bits dan gradientes más suaves e importan para imagen médica, HDR y pipelines de sensor raw.

Un píxel no es un cuadrado coloreado con área. Es una única medición. Cuando redimensionas o rotas, estás remuestreando esa grilla de mediciones.

Por qué tres canales

Un detector cuenta fotones en todo el espectro visible — eso es escala de grises. Para obtener color, el sensor cubre la grilla con un mosaico de filtros rojo, verde y azul. Tras el demosaicing, cada posición espacial tiene tres enteros: la respuesta del detector filtrado en rojo, en verde y en azul cercanos. Esos tres enteros son el tripleto RGB de un píxel.

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

Tres no es mágico. Las cámaras de profundidad agregan un canal Z. Los satélites agregan bandas de infrarrojo y ultravioleta. Los estudios médicos a menudo tienen un canal (rayos X, TC) o muchos (hiperespectral). El número de canales es el último eje; las capas conv aprenden a mezclar a lo largo de él.

Dos convenciones de layout: HWC y CHW

Mismo tensor, dos ordenamientos. Cada biblioteca elige uno.

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 los kernels de convolución se deslizan a lo largo de H y W. Mantener el eje de canales primero significa que cada kernel ve un plano 2D contiguo por canal, lo que vectoriza de forma limpia. Los formatos de disco mantienen HWC porque eso coincide con cómo salen las líneas de barrido de un sensor.

La conversión de una línea que vas a escribir mil veces:

img_chw = img_hwc.transpose(2, 0, 1)      # NumPy
img_chw = img_hwc.permute(2, 0, 1)        # PyTorch tensor

Layout de memoria, visualizado:

flowchart TB
    subgraph HWC["HWC — píxeles almacenados intercalados (PIL, OpenCV, JPEG)"]
        H1["fila 0: R G B | R G B | R G B ..."]
        H2["fila 1: R G B | R G B | R G B ..."]
        H3["fila 2: R G B | R G B | R G B ..."]
    end
    subgraph CHW["CHW — canales almacenados como planos apilados (PyTorch, cuDNN)"]
        C1["plano R: todo el H x W de valores rojos"]
        C2["plano G: todo el H x W de valores verdes"]
        C3["plano B: todo el H x W de valores azules"]
    end
    HWC -->|"transpose(2, 0, 1)"| CHW
    CHW -->|"transpose(1, 2, 0)"| HWC

Rangos de bytes y dtype

Tres convenciones dominan:

Convención dtype Rango Dónde lo ves
Raw uint8 [0, 255] Archivos en disco, PIL, salida de OpenCV
Normalizado float32 [0.0, 1.0] Tras img.astype('float32') / 255
Estandarizado float32 aproximadamente [-2, +2] Tras restar la media y dividir por la desviación

Las redes convolucionales fueron entrenadas con entradas estandarizadas. Las estadísticas de ImageNet mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225] son la media aritmética y la desviación estándar de los tres canales sobre todo el conjunto de entrenamiento de ImageNet, calculadas sobre píxeles normalizados en [0, 1]. Alimentar uint8 raw a un modelo que espera float estandarizado es la falla silenciosa más común en visión aplicada.

Espacios de color y por qué existen

RGB es el formato de captura, pero no siempre es la representación más útil para un 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 la mayoría de las CNN modernas alimentas RGB. Te topas con otros espacios cuando:

  • HSV — código clásico de CV, segmentación basada en color, balance de blancos.
  • YCbCr — lectura de internos de JPEG, pipelines de video, modelos de superresolución que operan solo sobre Y.
  • Escala de grises — OCR, modelos de documentos, cualquier caso donde el color sea variable de ruido en lugar de señal.

La escala de grises a partir de RGB es una suma ponderada, no un promedio, porque el ojo humano es más sensible al verde que al rojo o al azul:

Y = 0.299 R + 0.587 G + 0.114 B       (ITU-R BT.601, the classic weights)

Relación de aspecto, redimensionamiento e interpolación

Todo modelo tiene un tamaño de entrada fijo (224x224 para la mayoría de los clasificadores de ImageNet, 384x384 o 512x512 para detectores modernos). Tus imágenes rara vez coinciden. Las tres opciones de redimensionamiento que importan:

  • Redimensionar el lado más corto, luego center crop — la receta estándar de ImageNet. Preserva la relación de aspecto, descarta una franja de píxeles de borde.
  • Redimensionar y hacer padding — preserva la relación de aspecto y cada píxel, agrega barras negras. Estándar para detección y OCR.
  • Redimensionar directo al objetivo — estira la imagen. Barato, distorsiona la geometría, está bien para muchas tareas de clasificación.

El método de interpolación decide cómo se calculan los píxeles intermedios cuando la nueva grilla no se alinea con la antigua:

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

Regla práctica: bilinear para entrenamiento, bicubic o lanczos para assets que vas a mirar, nearest para cualquier cosa que contenga IDs de clase enteros.

Constrúyelo

Paso 1: Carga una imagen e inspecciona su shape

Usa Pillow para cargar cualquier JPEG o PNG, conviértela a NumPy e imprime lo que obtuviste. Para un ejemplo determinista que corre offline, sintetiza uno.

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]}")

Salida esperada: shape: (H, W, 3), dtype: uint8, rango [0, 255]. Esa es la representación canónica en disco, ya sea que los bytes vengan de una cámara, de un decodificador JPEG o de un generador sintético.

Paso 2: Separa los canales y reordena el layout

Extrae R, G, B por separado, luego convierte de HWC a CHW para 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}")

Tres planos en escala de grises, uno por canal. CHW solo reordena los ejes; no se requiere estrictamente ninguna copia de datos cuando el layout de memoria lo permite.

Paso 3: Conversiones a escala de grises y HSV

Escala de grises por suma ponderada, luego un RGB-a-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}]")

El matiz (hue) sale en grados, la saturación y el valor en [0, 1]. Eso coincide con la convención hsv_full de OpenCV.

Paso 4: Normaliza, estandariza y reviértelo

Ve de los bytes raw al tensor exacto que un modelo ImageNet preentrenado espera, y de vuelta.

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")

La media por canal debería quedar cerca de cero, la desviación cerca de uno. El par preprocess/deprocess es exactamente lo que hace por debajo toda llamada transforms.Normalize de torchvision.

Paso 5: Redimensiona con tres métodos de interpolación

Compara nearest, bilinear y bicubic en un upscale para que la diferencia sea visible.

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 puntúa más alto en rugosidad porque mantiene los bordes duros. Bilinear es el más suave. Bicubic queda en el medio, preservando la nitidez percibida sin los artefactos de escalón.

Úsalo

torchvision.transforms agrupa todo lo anterior en un único pipeline componible. El código de abajo reproduce exactamente lo que hace preprocess_imagenet, más resize y 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")

Cuatro pasos, exactamente en este orden: Resize(256) escala el lado más corto a 256; CenterCrop(224) toma un parche 224x224 del centro; ToTensor() divide por 255 e intercambia HWC por CHW; Normalize resta la media de ImageNet y divide por la desviación. Invertir ese orden cambia silenciosamente lo que llega al modelo.

Entrégalo

Esta lección produce:

  • outputs/prompt-vision-preprocessing-audit.md — un prompt que convierte cualquier model card o dataset card en una checklist de los invariantes exactos de preprocesamiento que un equipo debe respetar.
  • outputs/skill-image-tensor-inspector.md — una skill que, dado cualquier tensor o array con forma de imagen, reporta dtype, layout, rango y si parece raw, normalizado o estandarizado.

Ejercicios

  1. (Fácil) Carga un JPEG con OpenCV (cv2.imread) y con Pillow. Imprime ambos shapes y el píxel en (0, 0). Explica la diferencia de orden de canales, luego escribe una conversión de una línea que haga el array de OpenCV idéntico al de Pillow.
  2. (Medio) Escribe standardize(img, mean, std) y su inverso que juntos pasen una prueba de roundtrip_max_diff <= 1 en cualquier imagen uint8. Tus funciones deben funcionar sobre una sola imagen en HWC y sobre un batch en NCHW con la misma llamada.
  3. (Difícil) Toma un tensor de 3 canales estandarizado por ImageNet y pásalo por una conv 1x1 que aprende una mezcla ponderada de RGB en un único canal de escala de grises. Inicializa los pesos en [0.299, 0.587, 0.114], congélalos y verifica que la salida coincida con tu rgb_to_grayscale manual dentro del error de punto flotante. ¿Qué otras transformaciones clásicas de espacio de color pueden escribirse como convoluciones 1x1?

Términos clave

Término Lo que dice la gente Lo que realmente significa
Píxel "Un cuadrado coloreado" Una muestra de intensidad de luz en una posición de la grilla — tres números para color, uno para escala de grises
Canal "El color" Una de las grillas espaciales paralelas apiladas en un tensor de imagen; último eje en HWC, primero en CHW
HWC / CHW "El shape" Ordenamientos de ejes para un tensor de imagen; disco y PIL usan HWC, PyTorch y cuDNN usan CHW
Normalizar "Escalar la imagen" Dividir por 255 para que los píxeles vivan en [0, 1] — necesario pero no suficiente
Estandarizar "Centrar en cero" Restar la media y dividir por la desviación por canal para que la distribución de entrada coincida con aquella con la que se entrenó el modelo
Conversión a escala de grises "Promediar los canales" Una suma ponderada con coeficientes 0.299/0.587/0.114 que coincide con la percepción humana de luminancia
Interpolación "Cómo el resize elige píxeles" La regla que decide los valores de salida cuando la nueva grilla no se alinea con la antigua — nearest para labels, bilinear para entrenamiento, bicubic para visualización
Relación de aspecto "Ancho sobre alto" La razón que distingue "redimensionar y hacer padding" de "redimensionar y estirar"

Lectura Adicional

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