3D 비전 — 포인트 클라우드와 NeRF

3차원 비전(3D Vision)은 두 갈래로 볼 수 있습니다. 점 구름(Point Cloud)은 센서(Sensor)의 원시 출력이고, NeRF는 학습된 체적 장(Volumetric Field)입니다. 둘 다 "공간의 어디에 무엇이 있는가"에 답합니다.

유형: Learn + Build 언어: Python 선수 학습: Phase 4 Lesson 03 (CNN), Phase 1 Lesson 12 (Tensor Operations) 소요 시간: 약 45분

학습 목표

  • 명시적 3차원 표현(explicit representation: 점 구름(point cloud), 메시(mesh), 복셀(voxel))과 암시적 3차원 표현(implicit representation: 부호 거리 함수(signed distance field), NeRF)을 구분하고 각각이 언제 쓰이는지 이해합니다.
  • 순서가 없는 점 집합(point set)에서 신경망을 순서 불변(permutation-invariant)으로 만드는 PointNet의 대칭 함수(symmetric function) 기법을 이해합니다.
  • NeRF의 순전파(forward pass)를 광선 투사(ray casting), 체적 렌더링(volumetric rendering), 위치 인코딩(positional encoding), MLP 밀도+색상 헤드(density+colour head) 순서로 추적합니다.
  • nerfstudio 또는 instant-ngp로 자세(pose)가 있는 소수의 이미지에서 사전학습/실용적인 3차원 복원(3D reconstruction)을 수행합니다.

문제

카메라는 2차원 이미지를 만듭니다. 라이다(LIDAR)는 순서가 없는 3차원 점 집합을 만듭니다. 모션 기반 구조 복원(Structure-from-Motion) 파이프라인은 희소한(sparse) 3차원 키포인트 구름(keypoint cloud)을 만듭니다. NeRF는 자세가 있는 몇 장의 이미지에서 전체 3차원 장면(scene)을 재구성합니다. 모두 비전(vision) 문제이지만, CNN이 원하는 조밀한 텐서(dense tensor)처럼 생기지는 않았습니다.

3차원 비전이 중요한 이유는 거의 모든 고부가가치 로봇 작업이 3차원 공간에서 실행되기 때문입니다. 잡기(grasping), 장애물 회피(obstacle avoidance), 경로 탐색(navigation), 증강 현실(AR) 가림(occlusion), 3차원 콘텐츠 캡처가 모두 여기에 해당합니다. 2차원 이미지만 이해하는 비전 엔지니어는 AR/VR 콘텐츠, 로보틱스(robotics), 자율 주행(autonomous driving) 스택, 부동산이나 건설 분야를 위한 NeRF 기반 3차원 복원처럼 빠르게 성장하는 영역에 접근하기 어렵습니다.

두 표현은 서로 다른 이유로 지배적입니다. 점 구름은 센서가 거의 공짜로 주는 표현입니다. NeRF와 그 후속 표현인 3차원 가우시안 스플래팅(3D Gaussian Splatting), 신경 부호 거리 함수(neural SDF)는 신경망에게 장면을 학습하라고 했을 때 얻는 표현입니다.

사전 테스트

2문제 · 이 강의를 시작하기 전에 얼마나 알고 있는지 확인해보세요

1.일반 CNN이 점 구름(point cloud)을 직접 처리할 수 없는 이유는 무엇인가요?

2.PointNet을 입력 점 순서에 대해 순서 불변(permutation-invariant)으로 만드는 기법은 무엇인가요?

0/2 답변 완료

개념

점 구름(Point Cloud)

점 구름은 R^3 공간의 N개 점(point)으로 이루어진 순서 없는 집합(unordered set)입니다. 각 점에는 선택적으로 색상(colour), 강도(intensity), 법선(normal) 같은 특징(feature)이 붙을 수 있습니다.

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

격자(grid)도 없고 연결 구조(connectivity)도 없습니다. 이 때문에 신경망에 두 가지 어려움이 생깁니다.

  • 순서 불변성(Permutation Invariance) — 출력이 점의 순서에 의존하면 안 됩니다.
  • 가변 N(Variable N) — 하나의 모델이 서로 다른 점 개수를 가진 구름을 처리해야 합니다.

PointNet(Qi et al., 2017)은 하나의 아이디어로 둘을 해결했습니다. 모든 점에 공유 MLP(shared MLP)를 적용한 뒤, 대칭 함수(symmetric function)인 최댓값 풀링(max pool)으로 집계(aggregate)합니다. 결과는 순서에 의존하지 않는 고정 크기 벡터(fixed-size vector)입니다.

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

이것이 PointNet의 전체 핵심입니다. PointNet++, Point Transformer 같은 깊은 변형은 계층적 표본 추출(hierarchical sampling)과 지역 집계(local aggregation)를 추가하지만 대칭 함수 기법은 그대로 유지합니다.

PointNet 구조(Architecture)

flowchart LR
    PTS["N points<br/>(x, y, z)"] --> MLP1["shared MLP<br/>(64, 64)"]
    MLP1 --> MLP2["shared MLP<br/>(64, 128, 1024)"]
    MLP2 --> MAX["max pool<br/>(symmetric)"]
    MAX --> FEAT["global feature<br/>(1024,)"]
    FEAT --> FC["MLP classifier"]
    FC --> CLS["class logits"]

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

"공유 MLP"는 같은 MLP가 모든 점에 독립적으로 실행된다는 뜻입니다. 효율을 위해 점 차원(point dimension) 위의 1x1 합성곱(convolution)으로 구현합니다.

신경 복사장(Neural Radiance Fields; NeRFs)

NeRF(Mildenhall et al., 2020)는 "N장의 사진에서 3차원 장면을 재구성할 수 있는가?"라는 질문에, 장면 자체가 되는 신경망으로 답했습니다. 네트워크는 (x, y, z, viewing_direction)(밀도(density), 색상(colour))로 매핑합니다. 새로운 시점(view)을 렌더링(render)하는 것은 이 네트워크 위에서 광선 투사(ray-casting) 반복문을 도는 일입니다.

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

새 시점의 픽셀 (u, v)를 렌더링하려면:
  1. 카메라에서 픽셀 (u, v)를 지나는 광선(ray)을 쏩니다.
  2. 광선을 따라 거리 t_1, t_2, ..., t_N의 점을 표본 추출(sampling)합니다.
  3. 각 점에서 MLP를 조회(query)합니다.
  4. 색상을 (1 - exp(-sigma * dt))로 가중합성합니다.
  5. 그 합이 렌더링된 픽셀 색상입니다.

손실(loss)은 렌더링된 픽셀과 학습 사진의 정답(ground-truth) 픽셀을 비교합니다. 렌더링 단계를 통과해 역전파(backprop)하면 MLP가 업데이트됩니다. 3차원 정답도, 명시적 기하 구조(explicit geometry)도 없습니다. 장면은 MLP 가중치(weight) 안에 저장됩니다.

NeRF의 위치 인코딩(Positional Encoding)

원시(raw) (x, y, z)를 받은 기본(vanilla) MLP는 고주파(high-frequency) 디테일을 잘 표현하지 못합니다. MLP는 저주파(low-frequency) 쪽으로 스펙트럼 편향(spectral bias)이 있기 때문입니다. NeRF는 각 좌표(coordinate)를 MLP 전에 푸리에 특징 벡터(Fourier feature vector)로 인코딩(encoding)해 이를 해결합니다.

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

보통 L=10 주파수 단계까지 사용합니다. 이는 트랜스포머(transformer)의 위치 인코딩과 같은 기법이고, 확산 모델(diffusion)의 시간 조건화(time conditioning, Lesson 10)에서도 다시 등장합니다. 이것이 없으면 NeRF는 흐릿해집니다.

체적 렌더링(Volumetric Rendering)

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는 투과율(transmittance)입니다. 점 i까지 빛이 얼마나 살아남는지를 나타냅니다. (1 - exp(-sigma_i * delta_i))는 점 i에서의 불투명도(opacity)입니다. c_i는 색상입니다. 최종 픽셀은 광선을 따라 가중합(weighted sum)으로 계산됩니다.

NeRF를 대체한 것들

순수 NeRF는 학습이 느리고, 렌더링도 느립니다. 학습은 몇 시간이 걸리고 이미지 한 장을 렌더링하는 데 몇 초가 걸릴 수 있습니다. 이후 계보는 다음과 같습니다.

  • Instant-NGP(2022) — MLP의 위치 입력(position input)을 해시 격자 인코딩(hash-grid encoding)으로 대체해 몇 초 안에 학습합니다.
  • Mip-NeRF 360 — 경계 없는(unbounded) 장면과 안티에일리어싱(anti-aliasing)을 다룹니다.
  • 3D Gaussian Splatting(2023) — 체적 장(volumetric field)을 수백만 개의 3차원 가우시안으로 대체합니다. 몇 분 안에 학습하고 실시간(real-time)으로 렌더링합니다. 현재 프로덕션 기본값입니다.

2026년 기준 대부분의 실제 NeRF 제품은 사실상 3차원 가우시안 스플래팅을 사용합니다. 그래도 머릿속 모델(mental model)은 여전히 NeRF입니다.

데이터셋(Dataset)과 벤치마크(Benchmark)

  • ShapeNet — 3차원 CAD 모델을 점 구름으로 분류·분할(segmentation)합니다.
  • ScanNet — 실내 실제 스캔(real scan)의 분할 데이터입니다.
  • KITTI — 자율 주행용 실외 라이다 점 구름입니다.
  • NeRF Synthetic / Blended MVS — 시점 합성(view synthesis)을 위한 자세 포함 이미지(posed-image) 데이터셋입니다.
  • Mip-NeRF 360 dataset — 경계 없는 실제 장면입니다.

만들어보기

Step 1: PointNet 분류기(Classifier)

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) — Conv1d를 위해 전치(transpose)된 형태
        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"출력: {net(pts).shape}")
print(f"파라미터 수: {sum(p.numel() for p in net.parameters()):,}")

약 160만 개의 파라미터(parameter)를 가집니다. 구름마다 1,024개 점에서 실행됩니다.

Step 2: 위치 인코딩(Positional Encoding)

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"입력:  {x.shape}")
print(f"인코딩 결과: {y.shape}     # (5, 60)")

2^l * pi를 곱하면 점점 더 높은 주파수(frequency)를 제공합니다.

Step 3: Tiny NeRF MLP

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

원래 NeRF보다 훨씬 작습니다. 원래 모델은 깊이(depth) 8인 MLP 본체(trunk)를 두 개 갖습니다. 여기서는 구조를 보여주기에 충분한 크기로 둡니다.

Step 4: 광선을 따른 체적 렌더링

def volumetric_render(sigma, rgb, t_vals):
    """
    sigma: (..., N_samples)
    rgb:   (..., N_samples, 3)
    t_vals: (N_samples,) 광선 위의 거리
    """
    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.tolist()}")
print(f"깊이:           {depth.item():.2f}")

하나의 광선에서 64개 표본을 뽑고, 이를 하나의 RGB 픽셀과 깊이(depth)로 합성합니다.

활용하기

실무에서는 다음을 사용합니다.

  • nerfstudio(Tancik et al.) — NeRF / Instant-NGP / 가우시안 스플래팅의 현재 표준 참고 라이브러리(reference library)입니다. 커맨드라인(command-line)과 웹 뷰어(web viewer)를 제공합니다.
  • pytorch3d(Meta) — 미분 가능 렌더링(differentiable rendering), 점 구름 유틸리티, 메시 연산(mesh operation)을 제공합니다.
  • open3d — 점 구름 처리, 정합(registration), 시각화(visualisation)에 씁니다.

배포(deployment) 단계에서는 3차원 가우시안 스플래팅이 순수 NeRF를 상당 부분 대체했습니다. 100배 빠르게 렌더링하면서 복원 품질(reconstruction quality)은 비슷합니다.

산출물 만들기

이 레슨의 최종 산출물은 다음과 같습니다.

  • outputs/prompt-3d-task-router.md — 작업(task)과 입력 데이터에 따라 점 구름, 메시, 복셀, NeRF, 가우시안 스플랫(Gaussian Splat) 중 적절한 3차원 표현으로 라우팅(route)하는 프롬프트입니다.
  • outputs/skill-point-cloud-loader.md.ply / .pcd / .xyz 파일에 대해 정규화(normalization), 중심 정렬(centering), 점 표본 추출(point sampling)을 올바르게 수행하는 PyTorch Dataset을 작성하는 스킬(skill)입니다.

연습문제

  1. (쉬움) PointNet이 순서 불변함을 보입니다. 같은 구름을 한 번은 원래 순서로, 한 번은 점을 무작위로 섞어(shuffle) 넣고 출력이 부동소수점 잡음(floating-point noise) 수준으로 같은지 확인합니다.
  2. (중간) 카메라 내부 파라미터(intrinsics)와 자세가 주어졌을 때 H x W 이미지의 모든 픽셀에 대한 광선 원점(ray origin)과 방향(direction)을 만드는 최소 광선 생성 함수(ray-generation function)를 구현합니다.
  3. (어려움) 색이 있는 정육면체(coloured cube)의 렌더링된 시점으로 만든 합성 데이터셋에서 TinyNeRF를 학습합니다. 미분 가능 렌더링이나 간단한 광선 추적기(ray tracer)를 사용할 수 있습니다. 에포크(epoch) 1, 10, 100에서 렌더링 손실을 보고합니다. 몇 에포크부터 인식 가능한 시점이 나오나요?

핵심 용어

용어흔한 설명실제 의미
점 구름(Point Cloud)"라이다(LIDAR)에서 나온 3차원 점"점마다 선택적 특징(feature)이 붙은 순서 없는 (x, y, z) 집합입니다.
PointNet"점 구름용 첫 신경망"점별 공유 MLP와 대칭(최댓값) 풀로 구성됩니다. 구조적으로 순서 불변입니다.
NeRF"장면 자체인 MLP"(x, y, z, dir)(밀도, 색상)으로 매핑하는 네트워크입니다. 광선 투사로 렌더링합니다.
위치 인코딩(Positional Encoding)"푸리에 특징(Fourier features)"MLP의 저주파 편향을 넘기 위해 각 좌표를 여러 주파수의 sin/cos로 인코딩합니다.
체적 렌더링(Volumetric Rendering)"광선 적분(Ray Integration)"투과율과 알파(alpha)를 이용해 광선을 따라 표본을 합성해 하나의 픽셀을 만듭니다.
Instant-NGP"해시 격자 NeRF"NeRF의 좌표 MLP를 다중 해상도 해시 격자(multi-resolution hash grid)로 대체합니다. 100-1000배 빠릅니다.
3차원 가우시안 스플래팅(3D Gaussian Splatting)"수백만 개 가우시안"장면을 3차원 가우시안 집합으로 표현합니다. 실시간 렌더링과 분 단위 학습이 가능합니다.
부호 거리 함수(SDF; Signed Distance Field)"부호 거리 필드"가장 가까운 표면까지의 부호 있는 거리를 반환하는 함수입니다. 또 다른 암시적 표현입니다.

더 읽을거리

실습 코드

이 강의의 실습 코드 1개

main
Code

산출물

이 강의에서 생성된 프롬프트, 스킬, 코드 산출물 2개

skill-point-cloud-loader

Write a PyTorch Dataset for .ply / .pcd / .xyz files with correct normalisation, centring, and point sampling

Skill
prompt-3d-task-router

Route to the right 3D representation (point cloud, mesh, voxel, NeRF, Gaussian splat) based on task and input

Prompt

확인 문제

3문제 · 모두 맞추면 완료 표시가 가능합니다

1.원시(raw) `(x, y, z)` 좌표를 입력으로 받은 기본(vanilla) NeRF MLP가 흐릿한 결과를 만듭니다. 무엇이 이를 고치나요?

2.NeRF의 렌더링된 픽셀은 어떻게 계산되나요?

3.3차원 가우시안 스플래팅(3D Gaussian Splatting)이 프로덕션에서 NeRF를 크게 대체한 이유는 무엇인가요?

0/3 답변 완료