1. PointNet의 문제점
§01PointNet은 global max-pool을 사용하여 N개의 모든 점을 단일 1024차원 벡터로 집계합니다. 이는 의자와 비행기를 구분하는 데는 효과적이지만 모든 점을 독립적으로 처리하고 모든 국소적인 기하학적 구조를 버립니다. 전체 점의 분포가 비슷하면 평면 표면과 곡면을 구분할 수 없습니다.
더 나쁜 점은 실제 LiDAR 스캔은 불균일한 밀도로 고통받습니다. 센서 근처에는 점이 조밀하게 모여 있고 먼 곳에는 희소합니다. 균일한 샘플로 훈련된 네트워크는 밀도 변화에 대응할 메커니즘이 없기 때문에 밀도 변화 상황에서 일반화가 잘 되지 않습니다.
- 국소적 구조 없음 — N개의 모든 점에 대한 max pool은 이웃 관계를 완전히 무시하여 세부적인 모양(나사, 경첩)이 보이지 않음.
- 밀도 무시 — 고정된 global 집계는 밀도가 높은 근거리 영역과 희소한 원거리 영역에 동등한 가중치를 부여함.
- 계층 구조 없음 — 2D CNN은 점진적으로 수용 필드를 구축(가장자리 → 텍스처 → 부분 → 객체)하지만 PointNet은 한 단계에서 바로 global로 점프함.
2. 집합 추상화 계층
§02PointNet++는 집합 추상화(SA) 계층을 도입하는데, 이는 점 집합을 다운샘플링하면서 풍부한 국소 특성을 인코딩합니다. 각 SA 계층은 세 가지 부분 연산을 수행합니다:
- 최원점 샘플링(FPS) — 이미 선택된 모든 중심점으로부터 가장 먼 점을 반복적으로 선택합니다. 점 구름을 최대한 커버하는 M개의 중심점을 생성합니다. 무작위 샘플링보다 더 균등합니다.
- 구 쿼리(Ball Query) — 각 중심점에 대해 반경 r 내의 모든 점을 수집합니다. K개 이웃으로 제한됩니다. 국소 이웃은 평행이동 불변이며 밀도 적응적입니다.
- PointNet 미니 네트워크 — K개 이웃의 각 국소 그룹에 작은 PointNet(공유 MLP + max pool)을 적용합니다. 중심점당 d차원 특성 벡터 하나를 출력합니다. 결과: N개 점 → M개 점 및 더 풍부한 특성.
3. MSG — 다중 스케일 그룹화
§03불균일한 밀도는 고정된 단일 반경을 깨뜨립니다. 희소 영역에서 작은 반경은 이웃이 없고, 밀도 높은 영역에서 큰 반경은 너무 많은 이웃이 생겨 국소 해상도가 부족합니다. MSG는 여러 반경에서 병렬로 여러 구 쿼리를 실행하고 결과 특성 벡터를 연결합니다.
MRG(다중 해상도 그룹화)는 계산상 더 저렴한 대안입니다: 같은 SA 계층에서 여러 반径을 사용하는 대신, 계층 구조의 연속된 두 수준에서 특성을 연결합니다 — 하나는 원본 국소 점에서 계산되고, 하나는 더 거친 수준에서 전파됩니다. 국소 점 집합이 밀도가 높으면 원본 점 분기가 지배적이고, 희소하면 더 거친 특성이 대신합니다. MRG는 같은 수준에서 여러 PointNet을 실행하는 비용을 피합니다.
4. 분할을 위한 특성 전파
§04분류는 최상위 global 벡터만 필요하지만 점별 분할은 모든 원본 점에서 특성이 필요합니다. PointNet++는 U-Net의 디코더와 유사하게 보간 + 스킵 연결을 사용하여 계층 구조를 따라 특성을 역전파합니다.
더 세밀한 수준의 각 점에 대해, 특성은 더 거친 수준의 k개 최근접 이웃의 역거리 가중치 평균으로 계산됩니다:
f(x) = Σ w_i · f(x_i) / Σ w_i, w_i = 1 / dist(x, x_i)^p
보간된 특성은 인코더의 같은 수준에서 스킵 연결 특성과 연결된 후 단위 PointNet(점별 MLP)을 통과하여 정제됩니다. 여러 FP 계층을 쌓으면 전체 N점 해상도를 복원합니다.
5. PyTorch 구현
§05import torch
import torch.nn as nn
def farthest_point_sample(xyz, npoint):
# 반복적 최원점 샘플링. xyz: B×N×3, B×npoint 인덱스 반환.
B, N, _ = xyz.shape
centroids = torch.zeros(B, npoint, dtype=torch.long, device=xyz.device)
distance = torch.full((B, N), 1e10, device=xyz.device)
farthest = torch.randint(0, N, (B,), device=xyz.device)
for i in range(npoint):
centroids[:, i] = farthest
centroid = xyz[torch.arange(B), farthest].unsqueeze(1) # B×1×3
dist = ((xyz - centroid) ** 2).sum(-1) # B×N
distance = torch.min(distance, dist)
farthest = distance.argmax(-1)
return centroids
def ball_query(radius, nsample, xyz, new_xyz):
# 각 중심점에 대해 반경 내의 nsample 이웃 찾기.
B, N, _ = xyz.shape
M = new_xyz.shape[1]
# 쌍별 거리 B×M×N
dist = ((new_xyz.unsqueeze(2) - xyz.unsqueeze(1)) ** 2).sum(-1)
# 반경 외부의 점을 마스크; 정렬 후 처음 nsample개 선택
idx = dist.argsort(-1)[..., :nsample] # B×M×nsample
group_mask = dist.gather(-1, idx) > radius ** 2
idx[group_mask] = idx[:, :, :1].expand_as(idx)[group_mask] # 중심점으로 패딩
return idx # B×M×nsample
class SetAbstraction(nn.Module):
# 단일 집합 추상화 계층 (SSG 변형).
def __init__(self, npoint, radius, nsample, in_ch, mlp_channels):
super().__init__()
self.npoint = npoint
self.radius = radius
self.nsample = nsample
layers = []
last = in_ch + 3 # 상대 xyz 포함
for out_ch in mlp_channels:
layers += [nn.Conv2d(last, out_ch, 1),
nn.BatchNorm2d(out_ch), nn.ReLU()]
last = out_ch
self.mlp = nn.Sequential(*layers)
self.out_ch = last
def forward(self, xyz, features=None):
# xyz: B×N×3, features: B×N×C or None → new_xyz: B×M×3, new_feat: B×M×out_ch
B, N, _ = xyz.shape
# 1. FPS
idx = farthest_point_sample(xyz, self.npoint) # B×M
new_xyz = xyz[torch.arange(B).unsqueeze(1), idx] # B×M×3
# 2. 구 쿼리
grp_idx = ball_query(self.radius, self.nsample, xyz, new_xyz) # B×M×K
grp_xyz = xyz[torch.arange(B).view(B,1,1).expand_as(grp_idx),
grp_idx] # B×M×K×3
grp_xyz -= new_xyz.unsqueeze(2) # 상대 좌표
if features is not None:
grp_feat = features[torch.arange(B).view(B,1,1).expand_as(grp_idx),
grp_idx] # B×M×K×C
grp_in = torch.cat([grp_xyz, grp_feat], dim=-1) # B×M×K×(3+C)
else:
grp_in = grp_xyz # B×M×K×3
# 3. PointNet 미니 네트워크
grp_in = grp_in.permute(0, 3, 2, 1) # B×(3+C)×K×M
grp_out = self.mlp(grp_in) # B×out_ch×K×M
new_feat = grp_out.max(dim=2)[0].permute(0, 2, 1) # B×M×out_ch
return new_xyz, new_feat
# 빠른 테스트
if __name__ == "__main__":
xyz = torch.randn(4, 1024, 3)
sa1 = SetAbstraction(npoint=512, radius=0.2, nsample=32,
in_ch=0, mlp_channels=[64, 64, 128])
sa2 = SetAbstraction(npoint=128, radius=0.4, nsample=64,
in_ch=128, mlp_channels=[128, 128, 256])
xyz1, f1 = sa1(xyz)
xyz2, f2 = sa2(xyz1, f1)
print(xyz1.shape, f1.shape) # torch.Size([4, 512, 3]) torch.Size([4, 512, 128])
print(xyz2.shape, f2.shape) # torch.Size([4, 128, 3]) torch.Size([4, 128, 256])
6. 결과
§06| 작업 | 데이터셋 | 방법 | 지표 | 점수 |
|---|---|---|---|---|
| 분류 | ModelNet40 | PointNet | 정확도 | 89.2% |
| 분류 | ModelNet40 | PointNet++ (MSG) | 정확도 | 91.9% |
| 부분 분할 | ShapeNet Part | PointNet | mIoU | 83.7% |
| 부분 분할 | ShapeNet Part | PointNet++ | mIoU | 85.1% |
| 의미론적 분할 | S3DIS | PointNet | mIoU | 47.6% |
| 의미론적 분할 | S3DIS | PointNet++ | mIoU | 54.5% |
7. 해설
§07PointNet++는 PointNet의 자연스러운 후속작입니다. CNN이 합성곱 구조에서 "무료로" 얻는 계층적 국소-대역 처리를 추가합니다. 집합 추상화 계층은 개념적으로 3D 합성곱 계층의 아날로그입니다 — 중심점을 샘플링, 국소 이웃을 풀, 더 거친 표현을 출력 — 하지만 불규칙한 점 집합에 맞게 조정되었습니다.
MSG 밀도 처리는 우아하지만 비쌉니다: 각 SA 계층에서 K개의 독립적인 PointNet 부분 네트워크를 실행합니다. 실제로 많은 최신 아키텍처(PointConv, KPConv, VoteNet)는 FPS + 구 쿼리 골격을 차용하지만 내부 PointNet을 더 저렴한 연산으로 대체합니다.
유산: 거의 모든 후속 3D 탐지 또는 분할 네트워크(PointRCNN, VoteNet, 3DETR)는 PointNet++을 백본으로 사용합니다. FPS와 구 쿼리는 3D 심층 학습 라이브러리(PyTorch3D, MinkowskiEngine)의 표준 기본 단위가 되었습니다.