Phase 04 - Lesson 13

Visión 3D — Nubes de Puntos y NeRFs

La visión 3D viene en dos sabores. Las nubes de puntos son la salida cruda del sensor. Los NeRFs son el campo volumétrico aprendido. Ambos responden "qué hay dónde en el espacio".

Tipo: Aprender + Construir Lenguajes: Python Prerrequisitos: Fase 4 Lección 03 (CNNs), Fase 1 Lección 12 (Operaciones con Tensores) Tiempo: ~45 minutos

Objetivos de Aprendizaje

  • Distinguir representaciones 3D explícitas (nube de puntos, malla, vóxel) e implícitas (campo de distancia con signo, NeRF) y cuándo se usa cada una
  • Entender el truco de la función simétrica de PointNet que hace que una red neuronal sea invariante a permutaciones sobre un conjunto no ordenado de puntos
  • Seguir una pasada hacia adelante de un NeRF: lanzamiento de rayos, renderizado volumétrico, codificación posicional, MLP con cabeza de densidad+color
  • Usar nerfstudio o instant-ngp para reconstrucción 3D preentrenada a partir de un pequeño conjunto de imágenes con pose conocida

El Problema

Una cámara produce una imagen 2D. Un LIDAR produce un conjunto de puntos 3D sin orden. Un pipeline de structure-from-motion produce una nube dispersa de puntos clave 3D. Un NeRF reconstruye una escena 3D completa a partir de un puñado de imágenes con pose conocida. Todo esto es "visión", pero nada de esto se parece al tensor denso que una CNN quiere.

La visión 3D importa porque casi toda tarea robótica de alto valor opera en 3D: agarre, evasión de obstáculos, navegación, oclusión en RA, captura de contenido 3D. Un ingeniero de visión que solo entiende imágenes 2D queda excluido de la porción de mayor crecimiento del campo (contenido RA/RV, robótica, pilas de conducción autónoma, reconstrucción 3D basada en NeRF para bienes raíces o construcción).

Las dos representaciones dominan por razones distintas. Las nubes de puntos son lo que los sensores te entregan gratis. Los NeRFs y sus sucesores (3D Gaussian splatting, SDFs neuronales) son lo que obtienes cuando le pides a una red neuronal que aprenda una escena.

El Concepto

Nubes de puntos

Una nube de puntos es un conjunto no ordenado de N puntos en R^3, opcionalmente cada uno con características (color, intensidad, normal).

cloud = [
  (x1, y1, z1, r1, g1, b1),
  (x2, y2, z2, r2, g2, b2),
  ...
  (xN, yN, zN, rN, gN, bN),
]

Sin grilla, sin conectividad. Dos propiedades hacen esto difícil para las redes neuronales:

  • Invarianza a permutaciones — la salida no debe depender del orden de los puntos.
  • N variable — un solo modelo debe manejar nubes de distintos tamaños.

PointNet (Qi et al., 2017) resolvió ambas con una idea: aplicar una MLP compartida a cada punto, luego agregar con una función simétrica (max pool). El resultado es un vector de tamaño fijo que no depende del orden.

f(P) = max_{p in P} MLP(p)

Este es el núcleo completo de PointNet. Las variantes más profundas (PointNet++, Point Transformer) agregan muestreo jerárquico y agregación local, pero el truco de la función simétrica permanece sin cambios.

La arquitectura de PointNet

flowchart LR
    PTS["N puntos<br/>(x, y, z)"] --> MLP1["MLP compartida<br/>(64, 64)"]
    MLP1 --> MLP2["MLP compartida<br/>(64, 128, 1024)"]
    MLP2 --> MAX["max pool<br/>(simétrico)"]
    MAX --> FEAT["característica global<br/>(1024,)"]
    FEAT --> FC["clasificador MLP"]
    FC --> CLS["logits de clase"]

    style MLP1 fill:#dbeafe,stroke:#2563eb
    style MAX fill:#fef3c7,stroke:#d97706
    style CLS fill:#dcfce7,stroke:#16a34a

"MLP compartida" significa que la misma MLP corre en cada punto de forma independiente. Implementada como una convolución 1x1 sobre la dimensión de puntos por eficiencia.

Campos de Radiancia Neuronal (NeRFs)

Los NeRFs (Mildenhall et al., 2020) tomaron la pregunta "¿podemos reconstruir una escena 3D a partir de N fotos?" y respondieron con una red neuronal que es la escena. La red mapea (x, y, z, viewing_direction) a (density, colour). Renderizar una nueva vista es un bucle de lanzamiento de rayos sobre esta red.

NeRF MLP:  (x, y, z, theta, phi) -> (sigma, r, g, b)

To render a pixel (u, v) of a new view:
  1. Cast a ray from the camera through pixel (u, v)
  2. Sample points along the ray at distances t_1, t_2, ..., t_N
  3. Query the MLP at each point
  4. Composite the colours weighted by (1 - exp(-sigma * dt))
  5. The sum is the rendered pixel colour

Una pérdida compara el píxel renderizado con el píxel ground-truth en las fotos de entrenamiento. El backprop a través del paso de renderizado actualiza la MLP. Sin ground truth 3D, sin geometría explícita — la escena se almacena en los pesos de la MLP.

Codificación posicional en NeRF

Una MLP común sobre (x, y, z) no puede representar detalles de alta frecuencia porque las MLPs están espectralmente sesgadas hacia las bajas frecuencias. NeRF lo arregla codificando cada coordenada en un vector de características de Fourier antes de la MLP:

gamma(p) = (sin(2^0 pi p), cos(2^0 pi p), sin(2^1 pi p), cos(2^1 pi p), ...)

Hasta L=10 niveles de frecuencia. Este es el mismo truco que los transformers usan para las posiciones, y reaparece en el condicionamiento temporal de difusión (Lección 10). Sin él, los NeRFs se ven borrosos.

Renderizado volumétrico

C(r) = sum_i T_i * (1 - exp(-sigma_i * delta_i)) * c_i

T_i  = exp(- sum_{j<i} sigma_j * delta_j)
delta_i = t_{i+1} - t_i

T_i es la transmitancia — cuánta luz sobrevive hasta el punto i. (1 - exp(-sigma_i * delta_i)) es la opacidad en el punto i. c_i es el color. El píxel final es una suma ponderada a lo largo del rayo.

Qué reemplazó a los NeRFs

Los NeRFs puros son lentos de entrenar (horas) y lentos de renderizar (segundos por imagen). El linaje desde entonces:

  • Instant-NGP (2022) — la codificación por grilla de hash reemplaza la entrada de posición de la MLP; entrena en segundos.
  • Mip-NeRF 360 — maneja escenas sin límites y antialiasing.
  • 3D Gaussian Splatting (2023) — reemplaza el campo volumétrico con millones de gaussianas 3D; entrena en minutos, renderiza en tiempo real. El estándar de producción actual.

Casi todo producto NeRF real en 2026 es en realidad 3D Gaussian splatting. El modelo mental sigue siendo el NeRF.

Conjuntos de datos y benchmarks

  • ShapeNet — clasificación y segmentación de modelos CAD 3D como nubes de puntos.
  • ScanNet — escaneos interiores reales para segmentación.
  • KITTI — nubes de puntos LIDAR exteriores para conducción autónoma.
  • NeRF Synthetic / Blended MVS — conjuntos de imágenes con pose para síntesis de vistas.
  • Mip-NeRF 360 dataset — escenas reales sin límites.

Constrúyelo

Paso 1: Clasificador PointNet

import torch
import torch.nn as nn

class PointNet(nn.Module):
    def __init__(self, num_classes=10):
        super().__init__()
        self.mlp1 = nn.Sequential(
            nn.Conv1d(3, 64, 1),    nn.BatchNorm1d(64),   nn.ReLU(inplace=True),
            nn.Conv1d(64, 64, 1),   nn.BatchNorm1d(64),   nn.ReLU(inplace=True),
        )
        self.mlp2 = nn.Sequential(
            nn.Conv1d(64, 128, 1),  nn.BatchNorm1d(128),  nn.ReLU(inplace=True),
            nn.Conv1d(128, 1024, 1), nn.BatchNorm1d(1024), nn.ReLU(inplace=True),
        )
        self.head = nn.Sequential(
            nn.Linear(1024, 512),   nn.BatchNorm1d(512),  nn.ReLU(inplace=True),
            nn.Dropout(0.3),
            nn.Linear(512, 256),    nn.BatchNorm1d(256),  nn.ReLU(inplace=True),
            nn.Dropout(0.3),
            nn.Linear(256, num_classes),
        )

    def forward(self, x):
        # x: (N, 3, num_points) — transposed for Conv1d
        x = self.mlp1(x)
        x = self.mlp2(x)
        x = torch.max(x, dim=-1)[0]       # (N, 1024)
        return self.head(x)

pts = torch.randn(4, 3, 1024)
net = PointNet(num_classes=10)
print(f"output: {net(pts).shape}")
print(f"params: {sum(p.numel() for p in net.parameters()):,}")

Alrededor de 1,6M parámetros. Corre sobre 1.024 puntos por nube.

Paso 2: Codificación posicional

def positional_encoding(x, L=10):
    """
    x: (..., D) -> (..., D * 2 * L)
    """
    freqs = 2.0 ** torch.arange(L, dtype=x.dtype, device=x.device)
    args = x.unsqueeze(-1) * freqs * 3.141592653589793
    sinc = torch.cat([args.sin(), args.cos()], dim=-1)
    return sinc.reshape(*x.shape[:-1], -1)

x = torch.randn(5, 3)
y = positional_encoding(x, L=10)
print(f"input:  {x.shape}")
print(f"encoded: {y.shape}     # (5, 60)")

Multiplicar por 2^l * pi da frecuencias progresivamente más altas.

Paso 3: MLP Tiny NeRF

class TinyNeRF(nn.Module):
    def __init__(self, L_pos=10, L_dir=4, hidden=128):
        super().__init__()
        self.L_pos = L_pos
        self.L_dir = L_dir
        pos_dim = 3 * 2 * L_pos
        dir_dim = 3 * 2 * L_dir
        self.trunk = nn.Sequential(
            nn.Linear(pos_dim, hidden), nn.ReLU(inplace=True),
            nn.Linear(hidden, hidden),  nn.ReLU(inplace=True),
            nn.Linear(hidden, hidden),  nn.ReLU(inplace=True),
            nn.Linear(hidden, hidden),  nn.ReLU(inplace=True),
        )
        self.sigma = nn.Linear(hidden, 1)
        self.color = nn.Sequential(
            nn.Linear(hidden + dir_dim, hidden // 2), nn.ReLU(inplace=True),
            nn.Linear(hidden // 2, 3), nn.Sigmoid(),
        )

    def forward(self, x, d):
        x_enc = positional_encoding(x, self.L_pos)
        d_enc = positional_encoding(d, self.L_dir)
        h = self.trunk(x_enc)
        sigma = torch.relu(self.sigma(h)).squeeze(-1)
        rgb = self.color(torch.cat([h, d_enc], dim=-1))
        return sigma, rgb

nerf = TinyNeRF()
x = torch.randn(128, 3)
d = torch.randn(128, 3)
s, c = nerf(x, d)
print(f"sigma: {s.shape}   rgb: {c.shape}")

Diminuto comparado con el NeRF original (que tiene 2 troncos de MLP de profundidad 8). Suficiente para demostrar la arquitectura.

Paso 4: Renderizado volumétrico a lo largo de un rayo

def volumetric_render(sigma, rgb, t_vals):
    """
    sigma: (..., N_samples)
    rgb:   (..., N_samples, 3)
    t_vals: (N_samples,) distances along the ray
    """
    delta = torch.cat([t_vals[1:] - t_vals[:-1], torch.full_like(t_vals[:1], 1e10)])
    alpha = 1.0 - torch.exp(-sigma * delta)
    trans = torch.cumprod(torch.cat([torch.ones_like(alpha[..., :1]), 1.0 - alpha + 1e-10], dim=-1), dim=-1)[..., :-1]
    weights = alpha * trans
    rendered = (weights.unsqueeze(-1) * rgb).sum(dim=-2)
    depth = (weights * t_vals).sum(dim=-1)
    return rendered, depth, weights


N = 64
t_vals = torch.linspace(2.0, 6.0, N)
sigma = torch.rand(N) * 0.5
rgb = torch.rand(N, 3)
rendered, depth, weights = volumetric_render(sigma, rgb, t_vals)
print(f"rendered colour: {rendered.tolist()}")
print(f"depth:           {depth.item():.2f}")

Un rayo, 64 muestras, compuestas en un solo píxel RGB y una profundidad.

Úsalo

Para trabajo real:

  • nerfstudio (Tancik et al.) — la biblioteca de referencia actual para NeRF / Instant-NGP / Gaussian Splatting. Línea de comandos más un visor web.
  • pytorch3d (Meta) — renderizado diferenciable, utilidades de nube de puntos, operaciones de malla.
  • open3d — procesamiento de nube de puntos, registro, visualización.

Para despliegue, el 3D Gaussian splatting reemplazó en gran medida a los NeRFs puros porque renderiza 100x más rápido. La calidad de la reconstrucción es comparable.

Entrégalo

Esta lección produce:

  • outputs/prompt-3d-task-router.md — un prompt que enruta a la representación 3D correcta (nube de puntos, malla, vóxel, NeRF, Gaussian splat) según la tarea y los datos de entrada.
  • outputs/skill-point-cloud-loader.md — una skill que escribe un Dataset de PyTorch para archivos .ply / .pcd / .xyz con normalización, centrado y muestreo de puntos correctos.

Ejercicios

  1. (Fácil) Muestra que PointNet es invariante a permutaciones: pasa la misma nube dos veces, una vez con los puntos barajados. Verifica que las salidas sean idénticas salvo por el ruido de punto flotante.
  2. (Medio) Implementa una función mínima de generación de rayos que, dadas las intrínsecas y la pose de la cámara, produzca orígenes y direcciones de rayos para cada píxel de una imagen H x W.
  3. (Difícil) Entrena un TinyNeRF sobre un conjunto de datos sintético de vistas renderizadas de un cubo de colores (generado vía renderizado diferenciable o un ray tracer simple). Reporta la pérdida de renderizado en las épocas 1, 10 y 100. ¿En qué época el modelo produce vistas reconocibles?

Términos Clave

Término Lo que dice la gente Lo que realmente significa
Nube de puntos "Puntos 3D del LIDAR" Conjunto no ordenado de (x, y, z) + características opcionales por punto
PointNet "Primera red neuronal en nubes de puntos" MLP compartida por punto + pool simétrico (max); invariante a permutaciones por construcción
NeRF "MLP que es la escena" Red que mapea (x, y, z, dir) a (densidad, color); renderizada por lanzamiento de rayos
Codificación posicional "Características de Fourier" Codifica cada coordenada en sin/cos en múltiples frecuencias para superar el sesgo de baja frecuencia de la MLP
Renderizado volumétrico "Integración de rayo" Compone muestras a lo largo de un rayo en un solo píxel usando transmitancia y alfa
Instant-NGP "NeRF de grilla de hash" Reemplaza la MLP de coordenadas del NeRF con una grilla de hash multirresolución; 100-1000x más rápido
3D Gaussian splatting "Millones de gaussianas" Escena = colección de gaussianas 3D; renderiza en tiempo real, entrena en minutos
SDF "Campo de distancia con signo" Función que devuelve la distancia con signo a la superficie más cercana; otra representación implícita

Lectura Adicional

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