3D λΉ„μ „ 기초 (3D Vision Basics)

3D λΉ„μ „ 기초 (3D Vision Basics)

κ°œμš”

3D 비전은 2D μ΄λ―Έμ§€λ‘œλΆ€ν„° 3차원 정보λ₯Ό μΆ”μΆœν•˜κ³  λ³΅μ›ν•˜λŠ” κΈ°μˆ μž…λ‹ˆλ‹€. μŠ€ν…Œλ ˆμ˜€ λΉ„μ „, 깊이 λ§΅, 포인트 ν΄λΌμš°λ“œ 처리, 3D μž¬κ΅¬μ„±μ˜ 기초λ₯Ό λ‹€λ£Ήλ‹ˆλ‹€.

λ‚œμ΄λ„: ⭐⭐⭐⭐

μ„ μˆ˜ 지식: 카메라 μΊ˜λ¦¬λΈŒλ ˆμ΄μ…˜, νŠΉμ§•μ  κ²€μΆœ/λ§€μΉ­, μ„ ν˜•λŒ€μˆ˜


λͺ©μ°¨

  1. 3D λΉ„μ „ κ°œμš”
  2. μŠ€ν…Œλ ˆμ˜€ λΉ„μ „ 원리
  3. 깊이 λ§΅ 생성
  4. 포인트 ν΄λΌμš°λ“œ
  5. Open3D 기초
  6. 3D μž¬κ΅¬μ„±
  7. μ—°μŠ΅ 문제

1. 3D λΉ„μ „ κ°œμš”

3D λΉ„μ „μ˜ λͺ©ν‘œ

3D λΉ„μ „ νŒŒμ΄ν”„λΌμΈ:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                                                                  β”‚
β”‚  2D 이미지 ─────▢ 깊이 μΆ”μ • ─────▢ 3D μž¬κ΅¬μ„±                    β”‚
β”‚      β”‚                                                           β”‚
β”‚      β”‚           β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                                 β”‚
β”‚      └──────────▢│ 깊이 정보   │──────▢ 포인트 ν΄λΌμš°λ“œ          β”‚
β”‚                  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜            β”‚                    β”‚
β”‚                                             β”‚                    β”‚
β”‚                                             β–Ό                    β”‚
β”‚                                      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”             β”‚
β”‚                                      β”‚  3D 메쉬    β”‚             β”‚
β”‚                                      β”‚  3D λͺ¨λΈ    β”‚             β”‚
β”‚                                      β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜             β”‚
β”‚                                                                  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

깊이 μΆ”μΆœ 방법:
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ 방법                β”‚ μ„€λͺ…                                     β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ μŠ€ν…Œλ ˆμ˜€ λΉ„μ „       β”‚ 두 μΉ΄λ©”λΌμ˜ μ‹œμ°¨λ‘œ 깊이 계산             β”‚
β”‚ ꡬ쑰광 (Structured) β”‚ μ•Œλ €μ§„ νŒ¨ν„΄μ„ νˆ¬μ‚¬ν•˜μ—¬ 깊이 μΈ‘μ •         β”‚
β”‚ ToF (Time-of-Flight)β”‚ λΉ›μ˜ λΉ„ν–‰ μ‹œκ°„μœΌλ‘œ 거리 μΈ‘μ •             β”‚
β”‚ λ‹¨μ•ˆ 깊이 μΆ”μ •      β”‚ 단일 카메라 + λ”₯λŸ¬λ‹μœΌλ‘œ 깊이 예츑       β”‚
β”‚ LiDAR               β”‚ λ ˆμ΄μ € μŠ€μΊλ‹μœΌλ‘œ μ •λ°€ 깊이 μΈ‘μ •         β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

μ’Œν‘œκ³„ 이해

카메라 μ’Œν‘œκ³„:

        Y (μœ„)
        β”‚
        β”‚
        β”‚
        β”‚_________ X (였λ₯Έμͺ½)
       /
      /
     Z (카메라 μ •λ©΄ λ°©ν–₯)

μ›”λ“œ μ’Œν‘œκ³„ β†’ 카메라 μ’Œν‘œκ³„ λ³€ν™˜:
P_cam = R * P_world + t

이미지 μ’Œν‘œκ³„:
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Ά u (κ°€λ‘œ, ν”½μ…€)
β”‚
β”‚   ● (cx, cy) 주점
β”‚
β–Ό
v (μ„Έλ‘œ, ν”½μ…€)

3D β†’ 2D 투영:
u = fx * (X/Z) + cx
v = fy * (Y/Z) + cy

2. μŠ€ν…Œλ ˆμ˜€ λΉ„μ „ 원리

에피폴라 κΈ°ν•˜ν•™

에피폴라 κΈ°ν•˜ν•™ (Epipolar Geometry):

             에피폴 (e)
              β”‚
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚          β”‚          β”‚
   β”‚    ●─────┼──────────┼─────● 에피폴라 μ„ 
   β”‚   P      β”‚          β”‚   P'
   β”‚          β”‚          β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
       μ™Όμͺ½         였λ₯Έμͺ½
       이미지       이미지

3D 점 Pκ°€ μ™Όμͺ½ μ΄λ―Έμ§€μ˜ 점 p에 투영되면,
였λ₯Έμͺ½ μ΄λ―Έμ§€μ—μ„œλŠ” 에피폴라 μ„  μœ„ μ–΄λ”˜κ°€μ— p'둜 투영됨.

핡심 ν–‰λ ¬λ“€:
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ ν–‰λ ¬              β”‚ μ„€λͺ…                                    β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Essential Matrix  β”‚ μ •κ·œν™”λœ μ’Œν‘œκ³„μ—μ„œ κΈ°ν•˜ν•™μ  관계       β”‚
β”‚ (E)               β”‚ E = [t]x * R                            β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Fundamental Matrixβ”‚ ν”½μ…€ μ’Œν‘œκ³„μ—μ„œ κΈ°ν•˜ν•™μ  관계           β”‚
β”‚ (F)               β”‚ F = K'^(-T) * E * K^(-1)               β”‚
β”‚                   β”‚ p'^T * F * p = 0                        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

μ‹œμ°¨μ™€ 깊이

μŠ€ν…Œλ ˆμ˜€ μ‹œμ°¨ (Disparity):

μ™Όμͺ½ 카메라         였λ₯Έμͺ½ 카메라
    C_L ─────────────── C_R
     β”‚                    β”‚
     β”‚    b (베이슀라인)   β”‚
     β”‚    ◄─────────────► β”‚
     β”‚                    β”‚
     β”‚                    β”‚
     β–Ό                    β–Ό
    p_L        d        p_R
    ●─────────────────────●
    β”‚                     β”‚
    β”‚     μ‹œμ°¨ (d)        β”‚
    β”‚     d = x_L - x_R   β”‚

깊이 계산:
Z = (f * b) / d

μ—¬κΈ°μ„œ:
- Z: 깊이 (μΉ΄λ©”λΌλ‘œλΆ€ν„°μ˜ 거리)
- f: 초점 거리
- b: 베이슀라인 (두 카메라 사이 거리)
- d: μ‹œμ°¨ (ν”½μ…€ λ‹¨μœ„)

μ‹œμ°¨ λ²”μœ„ μ˜ˆμ‹œ:
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ 거리    β”‚ μ‹œμ°¨ (f=500, b=0.1m)          β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ 1m      β”‚ 50 ν”½μ…€                       β”‚
β”‚ 5m      β”‚ 10 ν”½μ…€                       β”‚
β”‚ 10m     β”‚ 5 ν”½μ…€                        β”‚
β”‚ λ¬΄ν•œλŒ€  β”‚ 0 ν”½μ…€                        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

μŠ€ν…Œλ ˆμ˜€ μ •ν•©

import cv2
import numpy as np

def stereo_calibrate(obj_points, img_points_left, img_points_right,
                     K1, D1, K2, D2, img_size):
    """μŠ€ν…Œλ ˆμ˜€ 카메라 μΊ˜λ¦¬λΈŒλ ˆμ΄μ…˜"""

    flags = (cv2.CALIB_FIX_INTRINSIC +
             cv2.CALIB_RATIONAL_MODEL)

    ret, K1, D1, K2, D2, R, T, E, F = cv2.stereoCalibrate(
        obj_points,
        img_points_left,
        img_points_right,
        K1, D1,
        K2, D2,
        img_size,
        flags=flags
    )

    print(f"μŠ€ν…Œλ ˆμ˜€ μΊ˜λ¦¬λΈŒλ ˆμ΄μ…˜ RMS 였차: {ret:.4f}")
    print(f"\nνšŒμ „ ν–‰λ ¬ R:\n{R}")
    print(f"\n평행 이동 벑터 T:\n{T.ravel()}")
    print(f"\n베이슀라인: {np.linalg.norm(T):.4f} λ‹¨μœ„")

    return R, T, E, F

def stereo_rectify(K1, D1, K2, D2, img_size, R, T):
    """μŠ€ν…Œλ ˆμ˜€ μ •λ₯˜ (Rectification)"""

    # μ •λ₯˜ λ³€ν™˜ 계산
    R1, R2, P1, P2, Q, roi1, roi2 = cv2.stereoRectify(
        K1, D1,
        K2, D2,
        img_size,
        R, T,
        alpha=0,  # 0: 유효 ν”½μ…€λ§Œ, 1: λͺ¨λ“  ν”½μ…€
        newImageSize=img_size
    )

    # Q ν–‰λ ¬: μ‹œμ°¨ β†’ 3D λ³€ν™˜μ— μ‚¬μš©
    # [X Y Z W]^T = Q * [x y disparity 1]^T
    print("Q ν–‰λ ¬ (μ‹œμ°¨ β†’ 3D λ³€ν™˜):")
    print(Q)

    return R1, R2, P1, P2, Q, roi1, roi2

def create_rectification_maps(K, D, R, P, img_size):
    """μ •λ₯˜ λ§΅ 생성"""

    map1, map2 = cv2.initUndistortRectifyMap(
        K, D, R, P, img_size, cv2.CV_32FC1
    )

    return map1, map2

def rectify_stereo_pair(img_left, img_right, maps_left, maps_right):
    """μŠ€ν…Œλ ˆμ˜€ 이미지 쌍 μ •λ₯˜"""

    rect_left = cv2.remap(img_left, maps_left[0], maps_left[1],
                          cv2.INTER_LINEAR)
    rect_right = cv2.remap(img_right, maps_right[0], maps_right[1],
                           cv2.INTER_LINEAR)

    return rect_left, rect_right

3. 깊이 λ§΅ 생성

StereoBM (Block Matching)

import cv2
import numpy as np

def compute_disparity_bm(left, right, num_disparities=64, block_size=15):
    """StereoBM을 μ΄μš©ν•œ μ‹œμ°¨ λ§΅ 계산"""

    # κ·Έλ ˆμ΄μŠ€μΌ€μΌ λ³€ν™˜
    if len(left.shape) == 3:
        left = cv2.cvtColor(left, cv2.COLOR_BGR2GRAY)
        right = cv2.cvtColor(right, cv2.COLOR_BGR2GRAY)

    # StereoBM 생성
    stereo = cv2.StereoBM_create(
        numDisparities=num_disparities,  # 16의 배수
        blockSize=block_size              # ν™€μˆ˜, 5~21
    )

    # νŒŒλΌλ―Έν„° μ‘°μ • (선택)
    stereo.setMinDisparity(0)
    stereo.setSpeckleWindowSize(100)
    stereo.setSpeckleRange(32)
    stereo.setPreFilterType(cv2.STEREO_BM_PREFILTER_NORMALIZED_RESPONSE)
    stereo.setPreFilterSize(9)
    stereo.setPreFilterCap(31)
    stereo.setTextureThreshold(10)
    stereo.setUniquenessRatio(15)

    # μ‹œμ°¨ 계산
    disparity = stereo.compute(left, right)

    # μ‹œμ°¨ κ°’ μ •κ·œν™” (16배둜 μŠ€μΌ€μΌλ˜μ–΄ 있음)
    disparity = disparity.astype(np.float32) / 16.0

    return disparity

def visualize_disparity(disparity):
    """μ‹œμ°¨ λ§΅ μ‹œκ°ν™”"""

    # μœ νš¨ν•œ μ‹œμ°¨λ§Œ μ‚¬μš©
    valid_mask = disparity > 0

    # μ •κ·œν™”
    disp_vis = np.zeros_like(disparity)
    if np.any(valid_mask):
        disp_min = np.min(disparity[valid_mask])
        disp_max = np.max(disparity[valid_mask])
        disp_vis = (disparity - disp_min) / (disp_max - disp_min) * 255

    disp_vis = disp_vis.astype(np.uint8)

    # 컬러맡 적용
    disp_color = cv2.applyColorMap(disp_vis, cv2.COLORMAP_JET)

    # μœ νš¨ν•˜μ§€ μ•Šμ€ μ˜μ—­μ€ κ²€μ€μƒ‰μœΌλ‘œ
    disp_color[~valid_mask] = [0, 0, 0]

    return disp_color

StereoSGBM (Semi-Global Block Matching)

def compute_disparity_sgbm(left, right, num_disparities=64, block_size=5):
    """StereoSGBM을 μ΄μš©ν•œ μ‹œμ°¨ λ§΅ 계산"""

    # κ·Έλ ˆμ΄μŠ€μΌ€μΌ λ³€ν™˜
    if len(left.shape) == 3:
        gray_left = cv2.cvtColor(left, cv2.COLOR_BGR2GRAY)
        gray_right = cv2.cvtColor(right, cv2.COLOR_BGR2GRAY)
    else:
        gray_left, gray_right = left, right

    # SGBM νŒŒλΌλ―Έν„°
    # P1, P2: 인접 ν”½μ…€ κ°„ μ‹œμ°¨ 차이에 λŒ€ν•œ νŽ˜λ„ν‹°
    P1 = 8 * 3 * block_size ** 2
    P2 = 32 * 3 * block_size ** 2

    stereo = cv2.StereoSGBM_create(
        minDisparity=0,
        numDisparities=num_disparities,
        blockSize=block_size,
        P1=P1,
        P2=P2,
        disp12MaxDiff=1,
        uniquenessRatio=10,
        speckleWindowSize=100,
        speckleRange=32,
        preFilterCap=63,
        mode=cv2.STEREO_SGBM_MODE_SGBM_3WAY
    )

    # μ‹œμ°¨ 계산
    disparity = stereo.compute(gray_left, gray_right)
    disparity = disparity.astype(np.float32) / 16.0

    return disparity

def disparity_to_depth(disparity, Q):
    """μ‹œμ°¨ 맡을 깊이 맡으둜 λ³€ν™˜"""

    # Q 행렬을 μ΄μš©ν•œ 3D 재투영
    # points_3d[y, x] = [X, Y, Z, W]
    points_3d = cv2.reprojectImageTo3D(disparity, Q)

    # Z κ°’ (깊이) μΆ”μΆœ
    depth = points_3d[:, :, 2]

    # μœ νš¨ν•˜μ§€ μ•Šμ€ 깊이 필터링
    valid_mask = (disparity > 0) & (depth > 0) & (depth < 10000)
    depth[~valid_mask] = 0

    return depth, points_3d

def create_depth_colormap(depth, max_depth=10.0):
    """깊이 λ§΅ μ‹œκ°ν™”"""

    # 깊이 클리핑
    depth_clipped = np.clip(depth, 0, max_depth)

    # μ •κ·œν™” (0-255)
    depth_norm = (depth_clipped / max_depth * 255).astype(np.uint8)

    # 컬러맡 적용 (κ°€κΉŒμš΄ = λΉ¨κ°•, λ¨Ό = νŒŒλž‘)
    depth_color = cv2.applyColorMap(255 - depth_norm, cv2.COLORMAP_JET)

    # μœ νš¨ν•˜μ§€ μ•Šμ€ μ˜μ—­ λ§ˆμŠ€ν‚Ή
    depth_color[depth <= 0] = [0, 0, 0]

    return depth_color

WLS ν•„ν„°λ₯Ό μ΄μš©ν•œ μ‹œμ°¨ κ°œμ„ 

def compute_disparity_with_wls(left, right, num_disparities=64):
    """WLS ν•„ν„°λ‘œ κ°œμ„ λœ μ‹œμ°¨ λ§΅ 계산"""

    # κ·Έλ ˆμ΄μŠ€μΌ€μΌ
    gray_left = cv2.cvtColor(left, cv2.COLOR_BGR2GRAY)
    gray_right = cv2.cvtColor(right, cv2.COLOR_BGR2GRAY)

    # μ™Όμͺ½ 맀처
    left_matcher = cv2.StereoSGBM_create(
        minDisparity=0,
        numDisparities=num_disparities,
        blockSize=5,
        P1=8 * 3 * 5 ** 2,
        P2=32 * 3 * 5 ** 2,
        disp12MaxDiff=1,
        uniquenessRatio=15,
        speckleWindowSize=0,
        speckleRange=2,
        preFilterCap=63,
        mode=cv2.STEREO_SGBM_MODE_SGBM_3WAY
    )

    # 였λ₯Έμͺ½ 맀처 (μ™Όμͺ½-였λ₯Έμͺ½ 일관성 κ²€μ‚¬μš©)
    right_matcher = cv2.ximgproc.createRightMatcher(left_matcher)

    # μ‹œμ°¨ 계산
    left_disp = left_matcher.compute(gray_left, gray_right)
    right_disp = right_matcher.compute(gray_right, gray_left)

    # WLS ν•„ν„°
    wls_filter = cv2.ximgproc.createDisparityWLSFilter(left_matcher)
    wls_filter.setLambda(80000)
    wls_filter.setSigmaColor(1.2)

    # ν•„ν„° 적용
    filtered_disp = wls_filter.filter(left_disp, left, None, right_disp)
    filtered_disp = filtered_disp.astype(np.float32) / 16.0

    return filtered_disp

4. 포인트 ν΄λΌμš°λ“œ

포인트 ν΄λΌμš°λ“œ 생성

import cv2
import numpy as np

def create_point_cloud(depth, rgb, K):
    """깊이 λ§΅κ³Ό RGB μ΄λ―Έμ§€λ‘œ 포인트 ν΄λΌμš°λ“œ 생성"""

    h, w = depth.shape
    fx, fy = K[0, 0], K[1, 1]
    cx, cy = K[0, 2], K[1, 2]

    # ν”½μ…€ μ’Œν‘œ κ·Έλ¦¬λ“œ
    u = np.arange(w)
    v = np.arange(h)
    u, v = np.meshgrid(u, v)

    # μœ νš¨ν•œ 깊이 마슀크
    valid = depth > 0

    # 3D μ’Œν‘œ 계산
    Z = depth[valid]
    X = (u[valid] - cx) * Z / fx
    Y = (v[valid] - cy) * Z / fy

    # 포인트 ν΄λΌμš°λ“œ (N x 3)
    points = np.stack([X, Y, Z], axis=-1)

    # 색상 정보 (N x 3)
    if len(rgb.shape) == 3:
        colors = rgb[valid]
    else:
        colors = np.stack([rgb[valid]] * 3, axis=-1)

    return points, colors

def subsample_point_cloud(points, colors, voxel_size=0.01):
    """볡셀 κ·Έλ¦¬λ“œλ‘œ 포인트 ν΄λΌμš°λ“œ λ‹€μš΄μƒ˜ν”Œλ§"""

    # 볡셀 인덱슀 계산
    voxel_indices = np.floor(points / voxel_size).astype(int)

    # κ³ μœ ν•œ λ³΅μ…€λ§Œ 선택
    _, unique_indices = np.unique(
        voxel_indices, axis=0, return_index=True
    )

    return points[unique_indices], colors[unique_indices]

def save_point_cloud_ply(filename, points, colors):
    """PLY ν˜•μ‹μœΌλ‘œ 포인트 ν΄λΌμš°λ“œ μ €μž₯"""

    n_points = len(points)

    # PLY 헀더
    header = f"""ply
format ascii 1.0
element vertex {n_points}
property float x
property float y
property float z
property uchar red
property uchar green
property uchar blue
end_header
"""

    with open(filename, 'w') as f:
        f.write(header)
        for i in range(n_points):
            x, y, z = points[i]
            r, g, b = colors[i]
            f.write(f"{x:.6f} {y:.6f} {z:.6f} {int(r)} {int(g)} {int(b)}\n")

    print(f"μ €μž₯됨: {filename} ({n_points} 포인트)")

포인트 ν΄λΌμš°λ“œ 처리

def remove_outliers_statistical(points, colors, nb_neighbors=20, std_ratio=2.0):
    """톡계적 μ΄μƒμΉ˜ 제거"""

    from scipy.spatial import KDTree

    # KD-Tree ꡬ좕
    tree = KDTree(points)

    # 각 점의 k-NN 거리 계산
    distances, _ = tree.query(points, k=nb_neighbors + 1)
    mean_distances = np.mean(distances[:, 1:], axis=1)  # 자기 μžμ‹  μ œμ™Έ

    # 전체 평균과 ν‘œμ€€νŽΈμ°¨
    global_mean = np.mean(mean_distances)
    global_std = np.std(mean_distances)

    # μ΄μƒμΉ˜ 마슀크
    threshold = global_mean + std_ratio * global_std
    inlier_mask = mean_distances < threshold

    print(f"μ΄μƒμΉ˜ 제거: {len(points)} β†’ {np.sum(inlier_mask)} 포인트")

    return points[inlier_mask], colors[inlier_mask]

def estimate_normals(points, k=30):
    """포인트 ν΄λΌμš°λ“œ 법선 벑터 μΆ”μ •"""

    from scipy.spatial import KDTree
    from numpy.linalg import eig

    tree = KDTree(points)
    normals = np.zeros_like(points)

    for i, point in enumerate(points):
        # k-NN 검색
        _, indices = tree.query(point, k=k)
        neighbors = points[indices]

        # 곡뢄산 ν–‰λ ¬
        centered = neighbors - np.mean(neighbors, axis=0)
        cov = np.dot(centered.T, centered) / k

        # κ°€μž₯ μž‘μ€ κ³ μœ κ°’μ˜ κ³ μœ λ²‘ν„°κ°€ 법선
        eigenvalues, eigenvectors = eig(cov)
        min_idx = np.argmin(eigenvalues)
        normals[i] = eigenvectors[:, min_idx]

    return normals

5. Open3D 기초

Open3D μ„€μΉ˜ 및 κΈ°λ³Έ μ‚¬μš©

# pip install open3d

import open3d as o3d
import numpy as np

def create_open3d_point_cloud(points, colors=None):
    """Open3D 포인트 ν΄λΌμš°λ“œ 생성"""

    pcd = o3d.geometry.PointCloud()
    pcd.points = o3d.utility.Vector3dVector(points)

    if colors is not None:
        # 색상을 0-1 λ²”μœ„λ‘œ μ •κ·œν™”
        if colors.max() > 1:
            colors = colors / 255.0
        pcd.colors = o3d.utility.Vector3dVector(colors)

    return pcd

def visualize_point_cloud(pcd):
    """포인트 ν΄λΌμš°λ“œ μ‹œκ°ν™”"""

    # μ’Œν‘œμΆ• μΆ”κ°€
    coordinate_frame = o3d.geometry.TriangleMesh.create_coordinate_frame(
        size=0.5, origin=[0, 0, 0]
    )

    o3d.visualization.draw_geometries(
        [pcd, coordinate_frame],
        window_name="Point Cloud",
        width=1280,
        height=720,
        point_show_normal=False
    )

def process_point_cloud_open3d(pcd):
    """Open3D둜 포인트 ν΄λΌμš°λ“œ 처리"""

    print(f"원본 포인트 수: {len(pcd.points)}")

    # 1. λ‹€μš΄μƒ˜ν”Œλ§
    pcd_down = pcd.voxel_down_sample(voxel_size=0.02)
    print(f"λ‹€μš΄μƒ˜ν”Œλ§ ν›„: {len(pcd_down.points)}")

    # 2. μ΄μƒμΉ˜ 제거
    pcd_clean, _ = pcd_down.remove_statistical_outlier(
        nb_neighbors=20,
        std_ratio=2.0
    )
    print(f"μ΄μƒμΉ˜ 제거 ν›„: {len(pcd_clean.points)}")

    # 3. 법선 μΆ”μ •
    pcd_clean.estimate_normals(
        search_param=o3d.geometry.KDTreeSearchParamHybrid(
            radius=0.1, max_nn=30
        )
    )

    # 4. 법선 λ°©ν–₯ μ •λ ¬
    pcd_clean.orient_normals_consistent_tangent_plane(k=15)

    return pcd_clean

메쉬 μž¬κ΅¬μ„±

def reconstruct_mesh_poisson(pcd, depth=9):
    """포아솑 ν‘œλ©΄ μž¬κ΅¬μ„±"""

    # 법선이 ν•„μš”ν•¨
    if not pcd.has_normals():
        pcd.estimate_normals()
        pcd.orient_normals_consistent_tangent_plane(k=15)

    # 포아솑 μž¬κ΅¬μ„±
    mesh, densities = o3d.geometry.TriangleMesh.create_from_point_cloud_poisson(
        pcd, depth=depth
    )

    # 저밀도 μ˜μ—­ 제거
    densities = np.asarray(densities)
    density_threshold = np.quantile(densities, 0.01)
    vertices_to_remove = densities < density_threshold
    mesh.remove_vertices_by_mask(vertices_to_remove)

    print(f"메쉬 정점 수: {len(mesh.vertices)}")
    print(f"메쉬 μ‚Όκ°ν˜• 수: {len(mesh.triangles)}")

    return mesh

def reconstruct_mesh_ball_pivoting(pcd):
    """λ³Ό ν”Όλ²—νŒ… ν‘œλ©΄ μž¬κ΅¬μ„±"""

    if not pcd.has_normals():
        pcd.estimate_normals()

    # 반경 μΆ”μ •
    distances = pcd.compute_nearest_neighbor_distance()
    avg_dist = np.mean(distances)
    radii = [avg_dist, avg_dist * 2, avg_dist * 4]

    mesh = o3d.geometry.TriangleMesh.create_from_point_cloud_ball_pivoting(
        pcd, o3d.utility.DoubleVector(radii)
    )

    return mesh

def save_mesh(mesh, filename):
    """메쉬 μ €μž₯"""
    o3d.io.write_triangle_mesh(filename, mesh)
    print(f"메쉬 μ €μž₯됨: {filename}")

RGBD 이미지 처리

def create_rgbd_from_opencv(color_img, depth_img, K):
    """OpenCV 이미지λ₯Ό Open3D RGBD둜 λ³€ν™˜"""

    # BGR β†’ RGB
    color_rgb = cv2.cvtColor(color_img, cv2.COLOR_BGR2RGB)

    # Open3D μ΄λ―Έμ§€λ‘œ λ³€ν™˜
    color_o3d = o3d.geometry.Image(color_rgb)
    depth_o3d = o3d.geometry.Image(depth_img.astype(np.float32))

    # RGBD 이미지 생성
    rgbd = o3d.geometry.RGBDImage.create_from_color_and_depth(
        color_o3d, depth_o3d,
        depth_scale=1000.0,  # mm β†’ m
        depth_trunc=3.0,     # μ΅œλŒ€ 깊이
        convert_rgb_to_intensity=False
    )

    return rgbd

def rgbd_to_point_cloud(rgbd, K, width, height):
    """RGBD μ΄λ―Έμ§€μ—μ„œ 포인트 ν΄λΌμš°λ“œ 생성"""

    # Open3D 카메라 νŒŒλΌλ―Έν„°
    intrinsic = o3d.camera.PinholeCameraIntrinsic(
        width, height,
        K[0, 0], K[1, 1],  # fx, fy
        K[0, 2], K[1, 2]   # cx, cy
    )

    # 포인트 ν΄λΌμš°λ“œ 생성
    pcd = o3d.geometry.PointCloud.create_from_rgbd_image(
        rgbd, intrinsic
    )

    return pcd

6. 3D μž¬κ΅¬μ„±

닀쀑 λ·° μŠ€ν…Œλ ˆμ˜€ (MVS) κ°œλ…

닀쀑 λ·° μŠ€ν…Œλ ˆμ˜€ νŒŒμ΄ν”„λΌμΈ:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                                                                 β”‚
β”‚  1. 이미지 μˆ˜μ§‘                                                 β”‚
β”‚     μ—¬λŸ¬ κ°λ„μ—μ„œ λŒ€μƒ 촬영                                     β”‚
β”‚         πŸ“· πŸ“· πŸ“· πŸ“· πŸ“·                                          β”‚
β”‚                                                                 β”‚
β”‚  2. νŠΉμ§•μ  κ²€μΆœ 및 λ§€μΉ­                                         β”‚
β”‚     SIFT, ORB λ“±μœΌλ‘œ 이미지 κ°„ λŒ€μ‘μ  μ°ΎκΈ°                      β”‚
β”‚         ● ─────────── ●                                         β”‚
β”‚                                                                 β”‚
β”‚  3. Structure from Motion (SfM)                                 β”‚
β”‚     카메라 포즈 μΆ”μ • + ν¬μ†Œ 포인트 ν΄λΌμš°λ“œ                     β”‚
β”‚         πŸ“·β”€β”€β”€β”€β”    ●                                            β”‚
β”‚         πŸ“·β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β— ●                                          β”‚
β”‚         πŸ“·β”€β”€β”€β”€β”˜    ●                                            β”‚
β”‚                                                                 β”‚
β”‚  4. μ‘°λ°€ μž¬κ΅¬μ„± (Dense Reconstruction)                          β”‚
β”‚     λͺ¨λ“  픽셀에 λŒ€ν•΄ 깊이 μΆ”μ •                                  β”‚
β”‚         [:::::::::::]                                           β”‚
β”‚                                                                 β”‚
β”‚  5. 메쉬 생성                                                   β”‚
β”‚     포인트 ν΄λΌμš°λ“œ β†’ μ‚Όκ°ν˜• 메쉬                               β”‚
β”‚         β–²β–²β–²β–²β–²β–²β–²β–²                                              β”‚
β”‚                                                                 β”‚
β”‚  6. ν…μŠ€μ²˜ λ§€ν•‘                                                 β”‚
β”‚     원본 μ΄λ―Έμ§€λ‘œ 메쉬에 ν…μŠ€μ²˜ 적용                            β”‚
β”‚                                                                 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Essential Matrix 기반 포즈 μΆ”μ •

import cv2
import numpy as np

def estimate_pose_from_essential(pts1, pts2, K):
    """Essential Matrix둜 μƒλŒ€ 포즈 μΆ”μ •"""

    # Essential Matrix 계산
    E, mask = cv2.findEssentialMat(
        pts1, pts2, K,
        method=cv2.RANSAC,
        prob=0.999,
        threshold=1.0
    )

    print(f"인라이어 λΉ„μœ¨: {np.sum(mask) / len(mask) * 100:.1f}%")

    # Essential Matrixμ—μ„œ R, t 볡ꡬ
    _, R, t, mask = cv2.recoverPose(E, pts1, pts2, K)

    print(f"\nνšŒμ „ ν–‰λ ¬ R:\n{R}")
    print(f"\n평행 이동 벑터 t (λ‹¨μœ„ 벑터):\n{t.ravel()}")

    return R, t

def triangulate_points(pts1, pts2, K, R, t):
    """두 λ·°μ—μ„œ 3D 점 μ‚Όκ°μΈ‘λŸ‰"""

    # 투영 ν–‰λ ¬ ꡬ성
    P1 = K @ np.hstack([np.eye(3), np.zeros((3, 1))])
    P2 = K @ np.hstack([R, t])

    # μ‚Όκ°μΈ‘λŸ‰
    pts1_h = pts1.T  # (2, N)
    pts2_h = pts2.T

    points_4d = cv2.triangulatePoints(P1, P2, pts1_h, pts2_h)

    # 동차 μ’Œν‘œ β†’ 3D μ’Œν‘œ
    points_3d = points_4d[:3] / points_4d[3]

    return points_3d.T  # (N, 3)

def incremental_sfm(images, K):
    """증뢄적 SfM (κ°„λ‹¨ν•œ 버전)"""

    # SIFT κ²€μΆœκΈ°
    sift = cv2.SIFT_create()

    # 첫 두 μ΄λ―Έμ§€λ‘œ μ΄ˆκΈ°ν™”
    kp1, desc1 = sift.detectAndCompute(images[0], None)
    kp2, desc2 = sift.detectAndCompute(images[1], None)

    # λ§€μΉ­
    bf = cv2.BFMatcher()
    matches = bf.knnMatch(desc1, desc2, k=2)

    # λΉ„μœ¨ ν…ŒμŠ€νŠΈ
    good_matches = []
    for m, n in matches:
        if m.distance < 0.75 * n.distance:
            good_matches.append(m)

    pts1 = np.float32([kp1[m.queryIdx].pt for m in good_matches])
    pts2 = np.float32([kp2[m.trainIdx].pt for m in good_matches])

    # 초기 포즈 및 3D 점
    R, t = estimate_pose_from_essential(pts1, pts2, K)
    points_3d = triangulate_points(pts1, pts2, K, R, t)

    # 카메라 포즈 μ €μž₯
    camera_poses = [
        {'R': np.eye(3), 't': np.zeros((3, 1))},  # 첫 번째 카메라
        {'R': R, 't': t}                           # 두 번째 카메라
    ]

    print(f"초기 3D 점 수: {len(points_3d)}")

    # 이후 이미지 μΆ”κ°€ (PnP둜 포즈 μΆ”μ •)
    for i in range(2, len(images)):
        kp_new, desc_new = sift.detectAndCompute(images[i], None)

        # 이전 이미지와 λ§€μΉ­
        matches = bf.knnMatch(desc2, desc_new, k=2)

        good_matches = []
        for m, n in matches:
            if m.distance < 0.75 * n.distance:
                good_matches.append(m)

        # 3D-2D λŒ€μ‘μ 
        obj_points = points_3d[[m.queryIdx for m in good_matches]]
        img_points = np.float32([kp_new[m.trainIdx].pt for m in good_matches])

        # PnP둜 포즈 μΆ”μ •
        success, rvec, tvec, inliers = cv2.solvePnPRansac(
            obj_points, img_points, K, None
        )

        if success:
            R_new, _ = cv2.Rodrigues(rvec)
            camera_poses.append({'R': R_new, 't': tvec})
            print(f"이미지 {i} 등둝 μ™„λ£Œ (인라이어: {len(inliers)})")

        # λ‹€μŒ λ°˜λ³΅μ„ μœ„ν•΄ μ—…λ°μ΄νŠΈ
        desc2 = desc_new

    return points_3d, camera_poses

λ²ˆλ“€ μ‘°μ • (Bundle Adjustment)

λ²ˆλ“€ μ‘°μ • (Bundle Adjustment):
카메라 νŒŒλΌλ―Έν„°μ™€ 3D 점 μœ„μΉ˜λ₯Ό λ™μ‹œμ— μ΅œμ ν™”

μ΅œμ†Œν™” λͺ©ν‘œ:
E = Ξ£_i Ξ£_j || x_ij - Ο€(K, R_i, t_i, X_j) ||Β²

μ—¬κΈ°μ„œ:
- x_ij: 이미지 iμ—μ„œ κ΄€μΈ‘λœ 점 j의 2D μ’Œν‘œ
- Ο€(): 3D β†’ 2D 투영 ν•¨μˆ˜
- K: 카메라 λ‚΄λΆ€ νŒŒλΌλ―Έν„°
- R_i, t_i: 카메라 i의 포즈
- X_j: 3D 점 j의 μ’Œν‘œ

μ΅œμ ν™” 도ꡬ:
- Ceres Solver
- g2o
- SciPy (μž‘μ€ 문제용)

7. μ—°μŠ΅ 문제

문제 1: μŠ€ν…Œλ ˆμ˜€ 깊이 μΆ”μ •

μŠ€ν…Œλ ˆμ˜€ 이미지 μŒμ—μ„œ 깊이 맡을 μƒμ„±ν•˜μ„Έμš”.

μš”κ΅¬μ‚¬ν•­: - StereoBMκ³Ό StereoSGBM 비ꡐ - μ‹œμ°¨ λ§΅ μ‹œκ°ν™” - 깊이 맡으둜 λ³€ν™˜ - ν’ˆμ§ˆ κ°œμ„  (필터링)

힌트
# νŒŒλΌλ―Έν„° νŠœλ‹ ν•„μš”
stereo = cv2.StereoSGBM_create(
    numDisparities=128,
    blockSize=5,
    P1=8 * 3 * 5 ** 2,
    P2=32 * 3 * 5 ** 2
)

# WLS ν•„ν„°λ‘œ κ°œμ„ 
wls_filter = cv2.ximgproc.createDisparityWLSFilter(stereo)

문제 2: 포인트 ν΄λΌμš°λ“œ 필터링

λ…Έμ΄μ¦ˆκ°€ μžˆλŠ” 포인트 ν΄λΌμš°λ“œλ₯Ό μ •μ œν•˜μ„Έμš”.

μš”κ΅¬μ‚¬ν•­: - 톡계적 μ΄μƒμΉ˜ 제거 - 볡셀 λ‹€μš΄μƒ˜ν”Œλ§ - 평면 μ˜μ—­ μΆ”μΆœ - κ²°κ³Ό μ‹œκ°ν™”

힌트
import open3d as o3d

# μ΄μƒμΉ˜ 제거
pcd_clean, _ = pcd.remove_statistical_outlier(
    nb_neighbors=20, std_ratio=2.0
)

# λ‹€μš΄μƒ˜ν”Œλ§
pcd_down = pcd_clean.voxel_down_sample(0.02)

# 평면 μΆ”μΆœ (RANSAC)
plane_model, inliers = pcd_down.segment_plane(
    distance_threshold=0.01,
    ransac_n=3,
    num_iterations=1000
)

문제 3: 두 λ·°μ—μ„œ 3D μž¬κ΅¬μ„±

두 μ΄λ―Έμ§€μ—μ„œ 3D 포인트λ₯Ό μž¬κ΅¬μ„±ν•˜μ„Έμš”.

μš”κ΅¬μ‚¬ν•­: - νŠΉμ§•μ  κ²€μΆœ 및 λ§€μΉ­ - Essential Matrix 계산 - 카메라 포즈 볡ꡬ - μ‚Όκ°μΈ‘λŸ‰μœΌλ‘œ 3D 점 생성

힌트
# Essential Matrix
E, mask = cv2.findEssentialMat(pts1, pts2, K)

# 포즈 볡ꡬ
_, R, t, _ = cv2.recoverPose(E, pts1, pts2, K)

# μ‚Όκ°μΈ‘λŸ‰
points_4d = cv2.triangulatePoints(P1, P2, pts1.T, pts2.T)
points_3d = points_4d[:3] / points_4d[3]

문제 4: 메쉬 μž¬κ΅¬μ„±

포인트 ν΄λΌμš°λ“œμ—μ„œ 3D 메쉬λ₯Ό μƒμ„±ν•˜μ„Έμš”.

μš”κ΅¬μ‚¬ν•­: - 포인트 ν΄λΌμš°λ“œ μ „μ²˜λ¦¬ - 법선 벑터 μΆ”μ • - 포아솑 λ˜λŠ” λ³Ό ν”Όλ²—νŒ… μž¬κ΅¬μ„± - κ²°κ³Ό μ €μž₯ 및 μ‹œκ°ν™”

힌트
# 법선 μΆ”μ •
pcd.estimate_normals()
pcd.orient_normals_consistent_tangent_plane(k=15)

# 포아솑 μž¬κ΅¬μ„±
mesh, densities = o3d.geometry.TriangleMesh.create_from_point_cloud_poisson(
    pcd, depth=9
)

# 저밀도 μ˜μ—­ 제거
densities = np.asarray(densities)
mesh.remove_vertices_by_mask(densities < np.quantile(densities, 0.01))

문제 5: μ‹€μ‹œκ°„ μŠ€ν…Œλ ˆμ˜€ λΉ„μ „

μ›ΉμΊ  λ˜λŠ” μŠ€ν…Œλ ˆμ˜€ μΉ΄λ©”λΌλ‘œ μ‹€μ‹œκ°„ 깊이 좔정을 κ΅¬ν˜„ν•˜μ„Έμš”.

μš”κ΅¬μ‚¬ν•­: - 카메라 μΊ˜λ¦¬λΈŒλ ˆμ΄μ…˜ 적용 - μ‹€μ‹œκ°„ μ‹œμ°¨ 계산 - 깊이 μ‹œκ°ν™” - FPS μΈ‘μ •

힌트
# 리맡핑 λ§΅ 미리 계산
map1_left, map2_left = cv2.initUndistortRectifyMap(...)
map1_right, map2_right = cv2.initUndistortRectifyMap(...)

while True:
    # μ •λ₯˜
    rect_left = cv2.remap(left, map1_left, map2_left, cv2.INTER_LINEAR)
    rect_right = cv2.remap(right, map1_right, map2_right, cv2.INTER_LINEAR)

    # μ‹œμ°¨ 계산 (SGBM)
    disparity = stereo.compute(rect_left, rect_right)

λ‹€μŒ 단계


참고 자료

to navigate between lessons