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
- (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. - (Medio) Escribe
standardize(img, mean, std)y su inverso que juntos pasen una prueba deroundtrip_max_diff <= 1en cualquier imagen uint8. Tus funciones deben funcionar sobre una sola imagen en HWC y sobre un batch en NCHW con la misma llamada. - (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 turgb_to_grayscalemanual 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
- Charles Poynton — A Guided Tour of Color Space — el tratamiento técnico más claro de por qué hay tantos espacios de color y cuándo importa cada uno
- PyTorch Vision Transforms Docs — el pipeline completo de transformaciones que de hecho vas a componer en producción
- How JPEG Works (Colt McAnlis) — un recorrido visual nítido sobre el submuestreo de croma, la DCT y por qué JPEG codifica YCbCr en lugar de RGB
- ImageNet Preprocessing Conventions (torchvision models) — la fuente de verdad para
mean=[0.485, 0.456, 0.406]y por qué todo modelo del zoo la espera