Phase 04 - Lesson 13

Visão 3D — Nuvens de Pontos e NeRFs

A visão 3D vem em dois sabores. Nuvens de pontos são a saída bruta do sensor. NeRFs são o campo volumétrico aprendido. Ambos respondem "o que está onde no espaço".

Tipo: Aprender + Construir Linguagens: Python Pré-requisitos: Fase 4 Lição 03 (CNNs), Fase 1 Lição 12 (Operações com Tensores) Tempo: ~45 minutos

Objetivos de Aprendizagem

  • Distinguir representações 3D explícitas (nuvem de pontos, malha, voxel) e implícitas (campo de distância com sinal, NeRF) e quando cada uma é usada
  • Entender o truque da função simétrica do PointNet que torna uma rede neural invariante a permutações sobre um conjunto não ordenado de pontos
  • Acompanhar uma passagem direta de um NeRF: lançamento de raios, renderização volumétrica, codificação posicional, MLP com cabeça de densidade+cor
  • Usar nerfstudio ou instant-ngp para reconstrução 3D pré-treinada a partir de um pequeno conjunto de imagens com pose conhecida

O Problema

Uma câmera produz uma imagem 2D. Um LIDAR produz um conjunto de pontos 3D sem ordenação. Um pipeline de structure-from-motion produz uma nuvem esparsa de pontos-chave 3D. Um NeRF reconstrói uma cena 3D inteira a partir de um punhado de imagens com pose conhecida. Tudo isso é "visão", mas nada disso se parece com o tensor denso que uma CNN quer.

A visão 3D importa porque quase toda tarefa robótica de alto valor roda em 3D: agarrar, evitar obstáculos, navegação, oclusão em RA, captura de conteúdo 3D. Um engenheiro de visão que só entende imagens 2D fica trancado fora da fatia que mais cresce no campo (conteúdo RA/RV, robótica, pilhas de direção autônoma, reconstrução 3D baseada em NeRF para imóveis ou construção).

As duas representações dominam por motivos diferentes. Nuvens de pontos são o que os sensores entregam de graça. NeRFs e seus sucessores (3D Gaussian splatting, SDFs neurais) são o que você obtém quando pede a uma rede neural para aprender uma cena.

O Conceito

Nuvens de pontos

Uma nuvem de pontos é um conjunto não ordenado de N pontos em R^3, opcionalmente cada um com features (cor, intensidade, normal).

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

Sem grade, sem conectividade. Duas propriedades tornam isso difícil para redes neurais:

  • Invariância a permutações — a saída não pode depender da ordem dos pontos.
  • N variável — um único modelo precisa lidar com nuvens de tamanhos diferentes.

O PointNet (Qi et al., 2017) resolveu ambos com uma ideia: aplicar uma MLP compartilhada a cada ponto, depois agregar com uma função simétrica (max pool). O resultado é um vetor de tamanho fixo que não depende da ordem.

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

Este é o núcleo inteiro do PointNet. Variantes mais profundas (PointNet++, Point Transformer) adicionam amostragem hierárquica e agregação local, mas o truque da função simétrica permanece inalterado.

A arquitetura do PointNet

flowchart LR
    PTS["N pontos<br/>(x, y, z)"] --> MLP1["MLP compartilhada<br/>(64, 64)"]
    MLP1 --> MLP2["MLP compartilhada<br/>(64, 128, 1024)"]
    MLP2 --> MAX["max pool<br/>(simétrico)"]
    MAX --> FEAT["feature global<br/>(1024,)"]
    FEAT --> FC["classificador MLP"]
    FC --> CLS["logits de classe"]

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

"MLP compartilhada" significa que a mesma MLP roda em cada ponto independentemente. Implementada como uma convolução 1x1 sobre a dimensão de pontos por eficiência.

Campos de Radiância Neural (NeRFs)

Os NeRFs (Mildenhall et al., 2020) pegaram a pergunta "podemos reconstruir uma cena 3D a partir de N fotos?" e responderam com uma rede neural que é a cena. A rede mapeia (x, y, z, viewing_direction) para (density, colour). Renderizar uma nova vista é um laço de lançamento de raios sobre essa rede.

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

Uma loss compara o pixel renderizado ao pixel ground-truth nas fotos de treino. O backprop através da etapa de renderização atualiza a MLP. Sem ground truth 3D, sem geometria explícita — a cena é armazenada nos pesos da MLP.

Codificação posicional em NeRF

Uma MLP comum sobre (x, y, z) não consegue representar detalhes de alta frequência porque MLPs são espectralmente enviesadas para baixas frequências. O NeRF corrige isso codificando cada coordenada em um vetor de features de Fourier antes da MLP:

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

Até L=10 níveis de frequência. Este é o mesmo truque que os transformers usam para posições, e ele reaparece no condicionamento temporal de difusão (Lição 10). Sem ele, os NeRFs ficam borrados.

Renderização volumétrica

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 é a transmitância — quanta luz sobrevive até o ponto i. (1 - exp(-sigma_i * delta_i)) é a opacidade no ponto i. c_i é a cor. O pixel final é uma soma ponderada ao longo do raio.

O que substituiu os NeRFs

NeRFs puros são lentos para treinar (horas) e lentos para renderizar (segundos por imagem). A linhagem desde então:

  • Instant-NGP (2022) — a codificação por grade de hash substitui a entrada de posição da MLP; treina em segundos.
  • Mip-NeRF 360 — lida com cenas ilimitadas e antialiasing.
  • 3D Gaussian Splatting (2023) — substitui o campo volumétrico por milhões de gaussianas 3D; treina em minutos, renderiza em tempo real. O padrão de produção atual.

Quase todo produto NeRF real em 2026 é na verdade 3D Gaussian splatting. O modelo mental ainda é o NeRF.

Conjuntos de dados e benchmarks

  • ShapeNet — classificação e segmentação de modelos CAD 3D como nuvens de pontos.
  • ScanNet — scans internos reais para segmentação.
  • KITTI — nuvens de pontos LIDAR ao ar livre para direção autônoma.
  • NeRF Synthetic / Blended MVS — conjuntos de imagens com pose para síntese de vistas.
  • Mip-NeRF 360 dataset — cenas reais ilimitadas.

Construa

Passo 1: Classificador 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()):,}")

Cerca de 1,6M parâmetros. Roda em 1.024 pontos por nuvem.

Passo 2: Codificação 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 dá frequências progressivamente mais altas.

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

Minúsculo comparado ao NeRF original (que tem 2 troncos de MLP de profundidade 8). Suficiente para demonstrar a arquitetura.

Passo 4: Renderização volumétrica ao longo de um raio

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

Um raio, 64 amostras, compostas em um único pixel RGB e uma profundidade.

Use

Para trabalho real:

  • nerfstudio (Tancik et al.) — a biblioteca de referência atual para NeRF / Instant-NGP / Gaussian Splatting. Linha de comando mais um visualizador web.
  • pytorch3d (Meta) — renderização diferenciável, utilitários de nuvem de pontos, operações de malha.
  • open3d — processamento de nuvem de pontos, registro, visualização.

Para implantação, o 3D Gaussian splatting substituiu em grande parte os NeRFs puros porque renderiza 100x mais rápido. A qualidade da reconstrução é comparável.

Entregue

Esta lição produz:

  • outputs/prompt-3d-task-router.md — um prompt que roteia para a representação 3D certa (nuvem de pontos, malha, voxel, NeRF, Gaussian splat) com base na tarefa e nos dados de entrada.
  • outputs/skill-point-cloud-loader.md — uma skill que escreve um Dataset do PyTorch para arquivos .ply / .pcd / .xyz com normalização, centralização e amostragem de pontos corretas.

Exercícios

  1. (Fácil) Mostre que o PointNet é invariante a permutações: rode a mesma nuvem duas vezes, uma vez com os pontos embaralhados. Verifique que as saídas são idênticas até o ruído de ponto flutuante.
  2. (Médio) Implemente uma função mínima de geração de raios que, dadas as intrínsecas e a pose da câmera, produza origens e direções de raios para cada pixel de uma imagem H x W.
  3. (Difícil) Treine um TinyNeRF em um conjunto de dados sintético de vistas renderizadas de um cubo colorido (gerado via renderização diferenciável ou um ray tracer simples). Reporte a loss de renderização nas épocas 1, 10 e 100. Em qual época o modelo produz vistas reconhecíveis?

Termos-Chave

Termo O que as pessoas dizem O que realmente significa
Nuvem de pontos "Pontos 3D do LIDAR" Conjunto não ordenado de (x, y, z) + features opcionais por ponto
PointNet "Primeira rede neural em nuvens de pontos" MLP compartilhada por ponto + pool simétrico (max); invariante a permutações por construção
NeRF "MLP que é a cena" Rede mapeando (x, y, z, dir) para (densidade, cor); renderizada por lançamento de raios
Codificação posicional "Features de Fourier" Codifica cada coordenada em sin/cos em múltiplas frequências para superar o viés de baixa frequência da MLP
Renderização volumétrica "Integração de raio" Compõe amostras ao longo de um raio em um único pixel usando transmitância e alfa
Instant-NGP "NeRF de grade de hash" Substitui a MLP de coordenadas do NeRF por uma grade de hash multirresolução; 100-1000x mais rápido
3D Gaussian splatting "Milhões de gaussianas" Cena = coleção de gaussianas 3D; renderiza em tempo real, treina em minutos
SDF "Campo de distância com sinal" Função que retorna a distância com sinal até a superfície mais próxima; outra representação implícita

Leitura Adicional

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