PointNet, 물체 분할, 6D 포즈 추정을 위한 3D 딥러닝
로봇 비전에서 3D 정보는 물체의 위치와 자세를 정확히 파악하는 데 필수적입니다. 다양한 3D 센서에서 생성되는 포인트 클라우드(Point Cloud)는 공간 상의 점들의 집합으로, (x, y, z) 좌표와 선택적으로 색상, 법선 정보를 포함합니다.
| 센서 타입 | 원리 | 정밀도 | 특징 |
|---|---|---|---|
| 스테레오 카메라 | 삼각측량 | ~1mm | 텍스처 필요, 가성비 좋음 |
| 구조광 (Structured Light) | 패턴 투사 | ~0.1mm | 고정밀, 반사면 취약 |
| ToF (Time of Flight) | 비행시간 | ~5mm | 넓은 범위, 속도 빠름 |
| LiDAR | 레이저 스캔 | ~2mm | 장거리, 야외용 |
원시 포인트 클라우드는 노이즈, 이상치, 과도한 밀도 등의 문제가 있어 전처리가 필요합니다.
import numpy as np
import open3d as o3d
from scipy.spatial import KDTree
class PointCloudPreprocessor:
"""포인트 클라우드 전처리 파이프라인"""
def __init__(self, voxel_size: float = 0.005,
statistical_k: int = 20,
statistical_std: float = 2.0):
self.voxel_size = voxel_size
self.statistical_k = statistical_k
self.statistical_std = statistical_std
def process(self, points: np.ndarray,
colors: np.ndarray = None) -> o3d.geometry.PointCloud:
"""전처리 파이프라인 실행"""
# Open3D 포인트 클라우드 생성
pcd = o3d.geometry.PointCloud()
pcd.points = o3d.utility.Vector3dVector(points)
if colors is not None:
pcd.colors = o3d.utility.Vector3dVector(colors)
# 1. 패스스루 필터 (ROI 추출)
pcd = self._passthrough_filter(pcd)
# 2. 다운샘플링
pcd = self._voxel_downsample(pcd)
# 3. 이상치 제거
pcd = self._remove_outliers(pcd)
# 4. 법선 추정
pcd = self._estimate_normals(pcd)
return pcd
def _passthrough_filter(self, pcd,
z_min: float = 0.01,
z_max: float = 1.5) -> o3d.geometry.PointCloud:
"""Z축 기준 ROI 필터링"""
points = np.asarray(pcd.points)
mask = (points[:, 2] >= z_min) & (points[:, 2] <= z_max)
filtered = pcd.select_by_index(np.where(mask)[0])
return filtered
def _voxel_downsample(self, pcd) -> o3d.geometry.PointCloud:
"""복셀 그리드 다운샘플링"""
return pcd.voxel_down_sample(voxel_size=self.voxel_size)
def _remove_outliers(self, pcd) -> o3d.geometry.PointCloud:
"""통계적 이상치 제거"""
pcd, _ = pcd.remove_statistical_outlier(
nb_neighbors=self.statistical_k,
std_ratio=self.statistical_std
)
return pcd
def _estimate_normals(self, pcd) -> o3d.geometry.PointCloud:
"""법선 벡터 추정"""
pcd.estimate_normals(
search_param=o3d.geometry.KDTreeSearchParamHybrid(
radius=0.02, max_nn=30
)
)
# 카메라 방향으로 법선 방향 통일
pcd.orient_normals_towards_camera_location(
camera_location=np.array([0, 0, 0])
)
return pcd
class PlaneSegmentation:
"""RANSAC 기반 평면 분할"""
def segment_plane(self, pcd: o3d.geometry.PointCloud,
distance_threshold: float = 0.01,
ransac_n: int = 3,
num_iterations: int = 1000):
"""
가장 큰 평면 분할 (테이블/바닥면 제거용)
Returns:
plane_model: 평면 방정식 계수 [a, b, c, d]
inliers: 평면에 속하는 점 인덱스
"""
plane_model, inliers = pcd.segment_plane(
distance_threshold=distance_threshold,
ransac_n=ransac_n,
num_iterations=num_iterations
)
return plane_model, inliers
def remove_plane(self, pcd: o3d.geometry.PointCloud,
distance_threshold: float = 0.01):
"""평면 제거 후 나머지 포인트 반환"""
_, inliers = self.segment_plane(pcd, distance_threshold)
# 평면이 아닌 점들 선택
return pcd.select_by_index(inliers, invert=True)PointNet은 순서 불변(permutation invariant)한 점 집합을 직접 처리하는 최초의 딥러닝 아키텍처입니다. PointNet++는 계층적 학습을 추가하여 지역 구조를 더 잘 캡처합니다.
import torch
import torch.nn as nn
import torch.nn.functional as F
class PointNetEncoder(nn.Module):
"""PointNet 인코더 - 글로벌 피처 추출"""
def __init__(self, input_channels=3, output_channels=1024):
super().__init__()
# T-Net: 입력 변환 학습
self.input_transform = TNet(k=input_channels)
# 공유 MLP (점별 처리)
self.mlp1 = nn.Sequential(
nn.Conv1d(input_channels, 64, 1),
nn.BatchNorm1d(64),
nn.ReLU(),
nn.Conv1d(64, 64, 1),
nn.BatchNorm1d(64),
nn.ReLU()
)
# 피처 변환
self.feature_transform = TNet(k=64)
# 후속 MLP
self.mlp2 = nn.Sequential(
nn.Conv1d(64, 128, 1),
nn.BatchNorm1d(128),
nn.ReLU(),
nn.Conv1d(128, output_channels, 1),
nn.BatchNorm1d(output_channels),
nn.ReLU()
)
def forward(self, x):
"""
Args:
x: (B, N, C) 포인트 클라우드
Returns:
global_feat: (B, 1024) 글로벌 피처
point_feat: (B, 64, N) 점별 피처
"""
B, N, C = x.shape
x = x.transpose(2, 1) # (B, C, N)
# 입력 변환
trans = self.input_transform(x)
x = torch.bmm(x.transpose(2, 1), trans).transpose(2, 1)
# MLP 1
x = self.mlp1(x) # (B, 64, N)
point_feat = x
# 피처 변환
trans_feat = self.feature_transform(x)
x = torch.bmm(x.transpose(2, 1), trans_feat).transpose(2, 1)
# MLP 2
x = self.mlp2(x) # (B, 1024, N)
# 맥스 풀링 (순서 불변성 핵심)
global_feat = torch.max(x, dim=2)[0] # (B, 1024)
return global_feat, point_feat
class TNet(nn.Module):
"""Transformation Network - 정규화 변환 학습"""
def __init__(self, k=3):
super().__init__()
self.k = k
self.conv = nn.Sequential(
nn.Conv1d(k, 64, 1),
nn.BatchNorm1d(64),
nn.ReLU(),
nn.Conv1d(64, 128, 1),
nn.BatchNorm1d(128),
nn.ReLU(),
nn.Conv1d(128, 1024, 1),
nn.BatchNorm1d(1024),
nn.ReLU()
)
self.fc = nn.Sequential(
nn.Linear(1024, 512),
nn.BatchNorm1d(512),
nn.ReLU(),
nn.Linear(512, 256),
nn.BatchNorm1d(256),
nn.ReLU(),
nn.Linear(256, k * k)
)
def forward(self, x):
B = x.shape[0]
x = self.conv(x)
x = torch.max(x, dim=2)[0]
x = self.fc(x)
# 단위 행렬에 학습된 변환 추가
iden = torch.eye(self.k, device=x.device).view(1, -1).repeat(B, 1)
x = x + iden
x = x.view(B, self.k, self.k)
return x
class PointNetPlusPlusSA(nn.Module):
"""PointNet++ Set Abstraction 레이어"""
def __init__(self, npoint, radius, nsample, in_channel, mlp):
super().__init__()
self.npoint = npoint
self.radius = radius
self.nsample = nsample
# PointNet MLP
mlp_layers = []
last_channel = in_channel + 3 # XYZ 추가
for out_channel in mlp:
mlp_layers.extend([
nn.Conv2d(last_channel, out_channel, 1),
nn.BatchNorm2d(out_channel),
nn.ReLU()
])
last_channel = out_channel
self.mlp = nn.Sequential(*mlp_layers)
def forward(self, xyz, points):
"""
Args:
xyz: (B, N, 3) 좌표
points: (B, N, C) 피처 (None 가능)
Returns:
new_xyz: (B, npoint, 3)
new_points: (B, npoint, mlp[-1])
"""
B, N, _ = xyz.shape
# Farthest Point Sampling
new_xyz = self._farthest_point_sample(xyz, self.npoint)
# Ball Query로 이웃점 그룹화
idx = self._ball_query(xyz, new_xyz, self.radius, self.nsample)
# 그룹화된 좌표 (상대 좌표로 변환)
grouped_xyz = self._index_points(xyz, idx) # (B, npoint, nsample, 3)
grouped_xyz -= new_xyz.unsqueeze(2)
# 피처 결합
if points is not None:
grouped_points = self._index_points(points, idx)
new_points = torch.cat([grouped_xyz, grouped_points], dim=-1)
else:
new_points = grouped_xyz
# (B, C, npoint, nsample) 형태로 변환
new_points = new_points.permute(0, 3, 1, 2)
# PointNet 적용
new_points = self.mlp(new_points)
# 맥스 풀링
new_points = torch.max(new_points, dim=3)[0] # (B, C, npoint)
new_points = new_points.permute(0, 2, 1) # (B, npoint, C)
return new_xyz, new_pointsPointNet의 핵심은 Max Pooling입니다. 이를 통해 점의 순서에 무관하게 동일한 글로벌 피처를 추출할 수 있습니다.
포인트 클라우드에서 개별 물체를 분할하는 것은 빈 피킹의 핵심입니다. 클러스터링 기반 방법과 딥러닝 기반 인스턴스 세그멘테이션을 모두 활용합니다.
class PointCloudSegmentation:
"""3D 포인트 클라우드 세그멘테이션"""
def euclidean_clustering(self, pcd: o3d.geometry.PointCloud,
eps: float = 0.02,
min_points: int = 50) -> List[np.ndarray]:
"""
DBSCAN 기반 유클리드 클러스터링
Args:
eps: 이웃 검색 반경
min_points: 클러스터 최소 점 수
"""
labels = np.array(pcd.cluster_dbscan(
eps=eps,
min_points=min_points,
print_progress=False
))
clusters = []
points = np.asarray(pcd.points)
max_label = labels.max()
for i in range(max_label + 1):
cluster_indices = np.where(labels == i)[0]
if len(cluster_indices) >= min_points:
clusters.append(points[cluster_indices])
return clusters
def region_growing(self, pcd: o3d.geometry.PointCloud,
angle_threshold: float = 30.0,
curvature_threshold: float = 0.05):
"""법선 기반 영역 성장 세그멘테이션"""
# 법선이 없으면 추정
if not pcd.has_normals():
pcd.estimate_normals()
points = np.asarray(pcd.points)
normals = np.asarray(pcd.normals)
n_points = len(points)
# KD-Tree 구축
kdtree = KDTree(points)
visited = np.zeros(n_points, dtype=bool)
clusters = []
for seed_idx in range(n_points):
if visited[seed_idx]:
continue
cluster = []
queue = [seed_idx]
while queue:
current_idx = queue.pop(0)
if visited[current_idx]:
continue
visited[current_idx] = True
cluster.append(current_idx)
# 이웃 검색
neighbors = kdtree.query_ball_point(points[current_idx], r=0.02)
for neighbor_idx in neighbors:
if visited[neighbor_idx]:
continue
# 법선 각도 검사
angle = np.arccos(np.clip(
np.dot(normals[current_idx], normals[neighbor_idx]),
-1.0, 1.0
))
if np.degrees(angle) < angle_threshold:
queue.append(neighbor_idx)
if len(cluster) >= 50:
clusters.append(points[cluster])
return clusters
class InstanceSegmentationNet(nn.Module):
"""딥러닝 기반 인스턴스 세그멘테이션"""
def __init__(self, num_classes: int, embed_dim: int = 64):
super().__init__()
# 백본: PointNet++ 인코더
self.encoder = PointNetPlusPlusEncoder()
# 시맨틱 세그멘테이션 헤드
self.semantic_head = nn.Sequential(
nn.Conv1d(128, 64, 1),
nn.BatchNorm1d(64),
nn.ReLU(),
nn.Conv1d(64, num_classes, 1)
)
# 인스턴스 임베딩 헤드
self.embedding_head = nn.Sequential(
nn.Conv1d(128, 64, 1),
nn.BatchNorm1d(64),
nn.ReLU(),
nn.Conv1d(64, embed_dim, 1)
)
def forward(self, xyz, features=None):
"""
Returns:
semantic_logits: (B, N, num_classes)
embeddings: (B, N, embed_dim)
"""
# 인코딩
point_features = self.encoder(xyz, features) # (B, 128, N)
# 시맨틱 예측
semantic = self.semantic_head(point_features) # (B, num_classes, N)
semantic = semantic.transpose(2, 1)
# 인스턴스 임베딩
embeddings = self.embedding_head(point_features) # (B, embed_dim, N)
embeddings = embeddings.transpose(2, 1)
embeddings = F.normalize(embeddings, p=2, dim=-1)
return semantic, embeddings
def get_instances(self, embeddings, semantic_pred,
bandwidth: float = 0.5):
"""Mean-shift 클러스터링으로 인스턴스 추출"""
from sklearn.cluster import MeanShift
instances = []
unique_classes = torch.unique(semantic_pred)
for cls in unique_classes:
if cls == 0: # 배경 스킵
continue
mask = semantic_pred == cls
cls_embeddings = embeddings[mask].cpu().numpy()
if len(cls_embeddings) < 10:
continue
# Mean-shift 클러스터링
ms = MeanShift(bandwidth=bandwidth)
cluster_labels = ms.fit_predict(cls_embeddings)
for inst_id in np.unique(cluster_labels):
inst_mask = cluster_labels == inst_id
instances.append({
'class': cls.item(),
'indices': np.where(mask)[0][inst_mask]
})
return instances6D 포즈 추정은 물체의 위치(x, y, z)와 자세(roll, pitch, yaw)를 예측하는 것으로, 로봇 그래스핑에 필수적입니다.
class PoseEstimator6D(nn.Module):
"""DenseFusion 스타일 6D 포즈 추정기"""
def __init__(self, num_objects: int, num_points: int = 1000):
super().__init__()
self.num_objects = num_objects
self.num_points = num_points
# RGB 특징 추출 (PSPNet 스타일)
self.rgb_encoder = RGBFeatureExtractor()
# 포인트 클라우드 특징 추출
self.point_encoder = PointNetEncoder(
input_channels=3, output_channels=512
)
# 특징 융합
self.fusion = nn.Sequential(
nn.Conv1d(512 + 256, 512, 1), # Point + RGB
nn.BatchNorm1d(512),
nn.ReLU(),
nn.Conv1d(512, 256, 1),
nn.BatchNorm1d(256),
nn.ReLU()
)
# 포즈 예측 헤드 (객체별)
self.pose_heads = nn.ModuleList([
PoseHead() for _ in range(num_objects)
])
def forward(self, rgb, depth, mask, obj_id):
"""
Args:
rgb: (B, 3, H, W) RGB 이미지
depth: (B, 1, H, W) 깊이 이미지
mask: (B, 1, H, W) 물체 마스크
obj_id: 객체 인덱스
Returns:
rotation: (B, num_points, 4) 쿼터니언
translation: (B, num_points, 3) 이동 벡터
confidence: (B, num_points) 신뢰도
"""
B = rgb.shape[0]
# 포인트 클라우드 추출
points = self._depth_to_points(depth, mask) # (B, N, 3)
# 포인트 클라우드 특징
point_feat, _ = self.point_encoder(points) # (B, 512)
point_feat = point_feat.unsqueeze(-1).repeat(1, 1, self.num_points)
# RGB 특징 추출 및 샘플링
rgb_feat = self.rgb_encoder(rgb) # (B, 256, H', W')
rgb_feat = self._sample_rgb_features(rgb_feat, points, mask) # (B, 256, N)
# 특징 융합
fused = torch.cat([point_feat, rgb_feat], dim=1) # (B, 768, N)
fused = self.fusion(fused) # (B, 256, N)
# 포즈 예측
pose_head = self.pose_heads[obj_id]
rotation, translation, confidence = pose_head(fused, points)
return rotation, translation, confidence
def predict_pose(self, rotation, translation, confidence):
"""최종 포즈 추출 (가중 평균)"""
# 신뢰도 기반 소프트맥스
weights = F.softmax(confidence, dim=1) # (B, N)
# 가중 평균 이동
pred_t = torch.sum(weights.unsqueeze(-1) * translation, dim=1) # (B, 3)
# 쿼터니언 가중 평균
pred_q = torch.sum(weights.unsqueeze(-1) * rotation, dim=1) # (B, 4)
pred_q = F.normalize(pred_q, p=2, dim=-1)
return pred_q, pred_t
class PoseHead(nn.Module):
"""단일 객체 포즈 예측 헤드"""
def __init__(self, feat_dim=256):
super().__init__()
# 회전 예측 (쿼터니언)
self.rotation_mlp = nn.Sequential(
nn.Conv1d(feat_dim + 3, 128, 1),
nn.BatchNorm1d(128),
nn.ReLU(),
nn.Conv1d(128, 4, 1)
)
# 이동 예측
self.translation_mlp = nn.Sequential(
nn.Conv1d(feat_dim + 3, 128, 1),
nn.BatchNorm1d(128),
nn.ReLU(),
nn.Conv1d(128, 3, 1)
)
# 신뢰도 예측
self.confidence_mlp = nn.Sequential(
nn.Conv1d(feat_dim + 3, 128, 1),
nn.BatchNorm1d(128),
nn.ReLU(),
nn.Conv1d(128, 1, 1)
)
def forward(self, features, points):
"""
Args:
features: (B, feat_dim, N)
points: (B, N, 3)
"""
# 좌표와 특징 결합
points_t = points.transpose(2, 1) # (B, 3, N)
x = torch.cat([features, points_t], dim=1) # (B, feat_dim+3, N)
# 예측
rotation = self.rotation_mlp(x).transpose(2, 1) # (B, N, 4)
rotation = F.normalize(rotation, p=2, dim=-1)
translation = self.translation_mlp(x).transpose(2, 1) # (B, N, 3)
confidence = self.confidence_mlp(x).squeeze(1) # (B, N)
return rotation, translation, confidence6D 포즈 추정의 정확도를 높이려면 CAD 모델과의 ICP 정합을 후처리로 추가하세요. DenseFusion + ICP 조합이 가장 안정적입니다.
실제 산업 환경에서 3D 포인트 클라우드 처리를 적용할 때의 고려사항입니다.
| 최적화 항목 | 방법 | 효과 |
|---|---|---|
| 추론 속도 | TensorRT, 포인트 샘플링 | 10x 가속 |
| 메모리 사용 | 다운샘플링, 16-bit 양자화 | 50% 절감 |
| 정밀도 | 다단계 정합 (Coarse-to-Fine) | ±0.5mm 달성 |
| 강건성 | 데이터 증강, 노이즈 학습 | 불량률 3% 이하 |
3D 센서는 반사면(금속, 유리)에서 노이즈가 발생합니다. 문제 발생 시 편광 필터, 매트 코팅, 또는 멀티뷰 촬영을 고려하세요.