SLAM μž…λ¬Έ (Visual SLAM Introduction)

SLAM μž…λ¬Έ (Visual SLAM Introduction)

κ°œμš”

SLAM (Simultaneous Localization and Mapping)은 λ‘œλ΄‡μ΄λ‚˜ μžμœ¨μ£Όν–‰ μ‹œμŠ€ν…œμ΄ λ―Έμ§€μ˜ ν™˜κ²½μ—μ„œ 지도λ₯Ό μž‘μ„±ν•˜λ©΄μ„œ λ™μ‹œμ— μžμ‹ μ˜ μœ„μΉ˜λ₯Ό μΆ”μ •ν•˜λŠ” κΈ°μˆ μž…λ‹ˆλ‹€. Visual SLAM, LiDAR SLAM, Loop Closure의 기초λ₯Ό λ‹€λ£Ήλ‹ˆλ‹€.

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

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


λͺ©μ°¨

  1. SLAM κ°œμš”
  2. Visual Odometry
  3. ORB-SLAM
  4. LiDAR SLAM
  5. Loop Closure
  6. SLAM κ΅¬ν˜„ μ‹€μŠ΅
  7. μ—°μŠ΅ 문제

1. SLAM κ°œμš”

SLAMμ΄λž€?

SLAM (Simultaneous Localization and Mapping):
λ™μ‹œμ  μœ„μΉ˜μΆ”μ • 및 μ§€λ„μž‘μ„±

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                                                                 β”‚
β”‚  핡심 질문:                                                     β”‚
β”‚  "지도 없이 μ–΄λ–»κ²Œ μœ„μΉ˜λ₯Ό μ•Œ 수 μžˆλŠ”κ°€?"                        β”‚
β”‚  "μœ„μΉ˜λ₯Ό λͺ¨λ₯΄λ©΄μ„œ μ–΄λ–»κ²Œ 지도λ₯Ό λ§Œλ“€ 수 μžˆλŠ”κ°€?"                β”‚
β”‚                                                                 β”‚
β”‚  β†’ λ‘˜μ„ λ™μ‹œμ— ν•΄κ²°! (λ‹­κ³Ό 달걀 문제)                           β”‚
β”‚                                                                 β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”‚
β”‚  β”‚                                                        β”‚     β”‚
β”‚  β”‚     μ„Όμ„œ 데이터                                        β”‚     β”‚
β”‚  β”‚     (카메라, LiDAR, IMU)                               β”‚     β”‚
β”‚  β”‚            β”‚                                           β”‚     β”‚
β”‚  β”‚            β–Ό                                           β”‚     β”‚
β”‚  β”‚     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                                   β”‚     β”‚
β”‚  β”‚     β”‚    SLAM      β”‚                                   β”‚     β”‚
β”‚  β”‚     β”‚   μ•Œκ³ λ¦¬μ¦˜   β”‚                                   β”‚     β”‚
β”‚  β”‚     β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜                                   β”‚     β”‚
β”‚  β”‚            β”‚                                           β”‚     β”‚
β”‚  β”‚     β”Œβ”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”                                   β”‚     β”‚
β”‚  β”‚     β”‚              β”‚                                   β”‚     β”‚
β”‚  β”‚     β–Ό              β–Ό                                   β”‚     β”‚
β”‚  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”                             β”‚     β”‚
β”‚  β”‚  β”‚  지도   β”‚  β”‚  μœ„μΉ˜   β”‚                             β”‚     β”‚
β”‚  β”‚  β”‚  (Map)  β”‚  β”‚ (Pose)  β”‚                             β”‚     β”‚
β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                             β”‚     β”‚
β”‚  β”‚                                                        β”‚     β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β”‚
β”‚                                                                 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

μ‘μš© λΆ„μ•Ό:
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ λΆ„μ•Ό            β”‚ μ˜ˆμ‹œ                                    β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ μžμœ¨μ£Όν–‰        β”‚ μžλ™μ°¨, λ“œλ‘ , 배달 λ‘œλ΄‡                 β”‚
β”‚ μ¦κ°•ν˜„μ‹€        β”‚ ARKit, ARCore, HoloLens                 β”‚
β”‚ λ‘œλ΄‡ μ²­μ†ŒκΈ°     β”‚ Roomba, Roborock                        β”‚
β”‚ 3D μŠ€μΊλ‹       β”‚ 건좕, λ¬Έν™”μž¬ 볡원                       β”‚
β”‚ λ‚΄λΉ„κ²Œμ΄μ…˜      β”‚ μ‹€λ‚΄ μœ„μΉ˜ 인식                          β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

SLAM λΆ„λ₯˜

SLAM 방식 λΆ„λ₯˜:

1. μ„Όμ„œ 기반 λΆ„λ₯˜
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                                                                 β”‚
β”‚  Visual SLAM (V-SLAM)                                           β”‚
β”‚  - 카메라 (λ‹¨μ•ˆ, μŠ€ν…Œλ ˆμ˜€, RGB-D)                               β”‚
β”‚  - νŠΉμ§•μ  기반 λ˜λŠ” 직접 방식                                   β”‚
β”‚  - 예: ORB-SLAM, LSD-SLAM, DSO                                 β”‚
β”‚                                                                 β”‚
β”‚  LiDAR SLAM                                                     β”‚
β”‚  - λ ˆμ΄μ € μŠ€μΊλ„ˆ                                                β”‚
β”‚  - 포인트 ν΄λΌμš°λ“œ λ§€μΉ­                                         β”‚
β”‚  - 예: Cartographer, LOAM, LeGO-LOAM                           β”‚
β”‚                                                                 β”‚
β”‚  Visual-Inertial SLAM                                           β”‚
β”‚  - 카메라 + IMU μœ΅ν•©                                            β”‚
β”‚  - 예: VINS-Mono, OKVIS, MSCKF                                 β”‚
β”‚                                                                 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

2. 방법둠 기반 λΆ„λ₯˜
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                                                                 β”‚
β”‚  ν•„ν„° 기반 (Filter-based)                                       β”‚
β”‚  - EKF-SLAM, UKF-SLAM                                          β”‚
β”‚  - μ‹€μ‹œκ°„ μ—…λ°μ΄νŠΈ                                              β”‚
β”‚  - μ„ ν˜•ν™” 였λ₯˜ λˆ„μ  문제                                        β”‚
β”‚                                                                 β”‚
β”‚  κ·Έλž˜ν”„ 기반 (Graph-based)                                      β”‚
β”‚  - 포즈 κ·Έλž˜ν”„ μ΅œμ ν™”                                           β”‚
β”‚  - λ²ˆλ“€ μ‘°μ •                                                    β”‚
β”‚  - 더 μ •ν™•ν•˜μ§€λ§Œ 계산 λΉ„μš© λ†’μŒ                                 β”‚
β”‚                                                                 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

3. ν”„λ‘ νŠΈμ—”λ“œ/λ°±μ—”λ“œ
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                                                                 β”‚
β”‚  ν”„λ‘ νŠΈμ—”λ“œ (Front-end)                                         β”‚
β”‚  - μ„Όμ„œ 데이터 처리                                             β”‚
β”‚  - νŠΉμ§• μΆ”μΆœ 및 λ§€μΉ­                                            β”‚
β”‚  - 초기 포즈 μΆ”μ •                                               β”‚
β”‚  - 루프 ν΄λ‘œμ € 탐지                                             β”‚
β”‚                                                                 β”‚
β”‚  λ°±μ—”λ“œ (Back-end)                                              β”‚
β”‚  - μ „μ—­ μ΅œμ ν™”                                                  β”‚
β”‚  - κ·Έλž˜ν”„ μ΅œμ ν™”                                                β”‚
β”‚  - λΆˆν™•μ‹€μ„± μΆ”μ •                                                β”‚
β”‚                                                                 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

2. Visual Odometry

Visual Odometry κ°œλ…

Visual Odometry (VO):
μ—°μ†λœ μ΄λ―Έμ§€λ‘œλΆ€ν„° 카메라 μ›€μ§μž„ μΆ”μ •

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                                                                 β”‚
β”‚  ν”„λ ˆμž„ t-1        ν”„λ ˆμž„ t          ν”„λ ˆμž„ t+1                 β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”        β”Œβ”€β”€β”€β”€β”€β”€β”€β”        β”Œβ”€β”€β”€β”€β”€β”€β”€β”                   β”‚
β”‚  β”‚   πŸ“·  │──T₁───▢│   πŸ“·  │──T₂───▢│   πŸ“·  β”‚                   β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”˜        β””β”€β”€β”€β”€β”€β”€β”€β”˜        β””β”€β”€β”€β”€β”€β”€β”€β”˜                   β”‚
β”‚                                                                 β”‚
β”‚  λˆ„μ  포즈: P_t = T₁ * Tβ‚‚ * ... * T_t                           β”‚
β”‚                                                                 β”‚
β”‚  문제점:                                                        β”‚
β”‚  - λˆ„μ  였차 (drift)                                            β”‚
β”‚  - μŠ€μΌ€μΌ λͺ¨ν˜Έμ„± (λ‹¨μ•ˆ 카메라)                                  β”‚
β”‚  - λΉ λ₯Έ μ›€μ§μž„μ— μ·¨μ•½                                           β”‚
β”‚                                                                 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

VO νŒŒμ΄ν”„λΌμΈ:
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                                                                 β”‚
β”‚  1. 이미지 νšλ“                                                 β”‚
β”‚       β–Ό                                                         β”‚
β”‚  2. νŠΉμ§• μΆ”μΆœ (ORB, SIFT, Harris corners)                       β”‚
β”‚       β–Ό                                                         β”‚
β”‚  3. νŠΉμ§• λ§€μΉ­/좔적 (BF Matcher, Optical Flow)                   β”‚
β”‚       β–Ό                                                         β”‚
β”‚  4. λͺ¨μ…˜ μΆ”μ • (Essential Matrix, PnP)                           β”‚
β”‚       β–Ό                                                         β”‚
β”‚  5. μ§€μ—­ μ΅œμ ν™” (Local BA)                                      β”‚
β”‚       β–Ό                                                         β”‚
β”‚  6. 포즈 μ—…λ°μ΄νŠΈ                                               β”‚
β”‚                                                                 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

λ‹¨μ•ˆ Visual Odometry κ΅¬ν˜„

import cv2
import numpy as np

class MonocularVO:
    """λ‹¨μ•ˆ Visual Odometry"""

    def __init__(self, K, detector='ORB'):
        """
        K: 카메라 λ‚΄λΆ€ νŒŒλΌλ―Έν„° ν–‰λ ¬
        detector: νŠΉμ§•μ  κ²€μΆœκΈ° ('ORB', 'SIFT', 'FAST')
        """
        self.K = K
        self.focal = K[0, 0]
        self.pp = (K[0, 2], K[1, 2])  # principal point

        # νŠΉμ§•μ  κ²€μΆœκΈ°
        if detector == 'ORB':
            self.detector = cv2.ORB_create(3000)
        elif detector == 'SIFT':
            self.detector = cv2.SIFT_create(3000)
        else:
            self.detector = cv2.FastFeatureDetector_create(threshold=25)

        # κ΄‘ν•™ 흐름 νŒŒλΌλ―Έν„°
        self.lk_params = dict(
            winSize=(21, 21),
            maxLevel=3,
            criteria=(cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 30, 0.01)
        )

        # μƒνƒœ
        self.prev_frame = None
        self.prev_pts = None
        self.cur_R = np.eye(3)
        self.cur_t = np.zeros((3, 1))
        self.trajectory = []

    def detect_features(self, img):
        """νŠΉμ§•μ  κ²€μΆœ"""
        if hasattr(self.detector, 'detectAndCompute'):
            kp, _ = self.detector.detectAndCompute(img, None)
        else:
            kp = self.detector.detect(img, None)

        pts = np.array([p.pt for p in kp], dtype=np.float32)
        return pts.reshape(-1, 1, 2)

    def track_features(self, prev_img, cur_img, prev_pts):
        """κ΄‘ν•™ νλ¦„μœΌλ‘œ νŠΉμ§•μ  좔적"""

        cur_pts, status, err = cv2.calcOpticalFlowPyrLK(
            prev_img, cur_img, prev_pts, None, **self.lk_params
        )

        status = status.reshape(-1)
        prev_pts = prev_pts[status == 1]
        cur_pts = cur_pts[status == 1]

        return prev_pts, cur_pts

    def estimate_pose(self, pts1, pts2):
        """Essential Matrix둜 포즈 μΆ”μ •"""

        E, mask = cv2.findEssentialMat(
            pts1, pts2, self.K,
            method=cv2.RANSAC,
            prob=0.999,
            threshold=1.0
        )

        _, R, t, mask = cv2.recoverPose(E, pts1, pts2, self.K)

        return R, t

    def process_frame(self, frame):
        """ν”„λ ˆμž„ 처리"""

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

        if self.prev_frame is None:
            # 첫 ν”„λ ˆμž„
            self.prev_frame = gray
            self.prev_pts = self.detect_features(gray)
            return self.cur_R, self.cur_t

        # νŠΉμ§•μ  좔적
        if self.prev_pts is not None and len(self.prev_pts) > 0:
            prev_pts, cur_pts = self.track_features(
                self.prev_frame, gray, self.prev_pts
            )

            if len(prev_pts) >= 8:
                # 포즈 μΆ”μ •
                R, t = self.estimate_pose(
                    prev_pts.reshape(-1, 2),
                    cur_pts.reshape(-1, 2)
                )

                # 포즈 λˆ„μ 
                self.cur_t = self.cur_t + self.cur_R @ t
                self.cur_R = R @ self.cur_R

                # μƒˆ νŠΉμ§•μ μ΄ ν•„μš”ν•˜λ©΄ κ²€μΆœ
                if len(cur_pts) < 1000:
                    new_pts = self.detect_features(gray)
                    if len(cur_pts) > 0:
                        self.prev_pts = np.vstack([
                            cur_pts.reshape(-1, 1, 2),
                            new_pts
                        ])
                    else:
                        self.prev_pts = new_pts
                else:
                    self.prev_pts = cur_pts.reshape(-1, 1, 2)
            else:
                self.prev_pts = self.detect_features(gray)
        else:
            self.prev_pts = self.detect_features(gray)

        self.prev_frame = gray

        # ꢀ적 μ €μž₯
        self.trajectory.append(self.cur_t.copy())

        return self.cur_R, self.cur_t

    def get_trajectory(self):
        """ꢀ적 λ°˜ν™˜"""
        return np.array([t.ravel() for t in self.trajectory])

# μ‚¬μš© 예
K = np.array([
    [718.856, 0, 607.1928],
    [0, 718.856, 185.2157],
    [0, 0, 1]
], dtype=np.float32)

vo = MonocularVO(K)

cap = cv2.VideoCapture('driving.mp4')

while cap.isOpened():
    ret, frame = cap.read()
    if not ret:
        break

    R, t = vo.process_frame(frame)

    # ν˜„μž¬ μœ„μΉ˜ 좜λ ₯
    x, y, z = t.ravel()
    print(f"Position: x={x:.2f}, y={y:.2f}, z={z:.2f}")

cap.release()

# ꢀ적 μ‹œκ°ν™”
trajectory = vo.get_trajectory()

μŠ€ν…Œλ ˆμ˜€ Visual Odometry

class StereoVO:
    """μŠ€ν…Œλ ˆμ˜€ Visual Odometry"""

    def __init__(self, K, baseline, detector='ORB'):
        self.K = K
        self.baseline = baseline
        self.focal = K[0, 0]

        self.detector = cv2.ORB_create(3000)
        self.bf = cv2.BFMatcher(cv2.NORM_HAMMING)

        # μŠ€ν…Œλ ˆμ˜€ 맀처
        self.stereo = cv2.StereoSGBM_create(
            minDisparity=0,
            numDisparities=128,
            blockSize=5,
            P1=8 * 3 * 5 ** 2,
            P2=32 * 3 * 5 ** 2
        )

        self.prev_pts_3d = None
        self.prev_kp = None
        self.prev_desc = None
        self.cur_R = np.eye(3)
        self.cur_t = np.zeros((3, 1))

    def compute_depth(self, left, right):
        """μŠ€ν…Œλ ˆμ˜€ 맀칭으둜 깊이 계산"""

        disparity = self.stereo.compute(left, right).astype(np.float32) / 16.0

        # μ‹œμ°¨ β†’ 깊이
        depth = np.zeros_like(disparity)
        valid = disparity > 0
        depth[valid] = self.focal * self.baseline / disparity[valid]

        return depth

    def get_3d_points(self, kp, depth):
        """2D ν‚€ν¬μΈνŠΈλ₯Ό 3D둜 λ³€ν™˜"""

        fx = self.K[0, 0]
        fy = self.K[1, 1]
        cx = self.K[0, 2]
        cy = self.K[1, 2]

        pts_3d = []
        valid_indices = []

        for i, pt in enumerate(kp):
            x, y = int(pt.pt[0]), int(pt.pt[1])

            if 0 <= x < depth.shape[1] and 0 <= y < depth.shape[0]:
                z = depth[y, x]

                if z > 0 and z < 100:  # μœ νš¨ν•œ 깊이
                    X = (pt.pt[0] - cx) * z / fx
                    Y = (pt.pt[1] - cy) * z / fy
                    pts_3d.append([X, Y, z])
                    valid_indices.append(i)

        return np.array(pts_3d), valid_indices

    def process_frame(self, left, right):
        """μŠ€ν…Œλ ˆμ˜€ ν”„λ ˆμž„ 처리"""

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

        # 깊이 계산
        depth = self.compute_depth(gray_left, gray_right)

        # νŠΉμ§• κ²€μΆœ
        kp, desc = self.detector.detectAndCompute(gray_left, None)

        # 3D 점 계산
        pts_3d, valid_idx = self.get_3d_points(kp, depth)

        if self.prev_pts_3d is None:
            self.prev_pts_3d = pts_3d
            self.prev_kp = [kp[i] for i in valid_idx]
            self.prev_desc = desc[valid_idx]
            return self.cur_R, self.cur_t

        # 이전 ν”„λ ˆμž„κ³Ό λ§€μΉ­
        matches = self.bf.knnMatch(self.prev_desc, desc[valid_idx], k=2)

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

        if len(good_matches) >= 6:
            # 3D-2D λŒ€μ‘μ 
            obj_points = np.array([
                self.prev_pts_3d[m.queryIdx] for m in good_matches
            ])
            img_points = np.array([
                kp[valid_idx[m.trainIdx]].pt for m in good_matches
            ])

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

            if success and inliers is not None and len(inliers) > 10:
                R, _ = cv2.Rodrigues(rvec)

                # 포즈 λˆ„μ 
                self.cur_t = self.cur_t + self.cur_R @ tvec
                self.cur_R = R @ self.cur_R

        # μƒνƒœ μ—…λ°μ΄νŠΈ
        self.prev_pts_3d = pts_3d
        self.prev_kp = [kp[i] for i in valid_idx]
        self.prev_desc = desc[valid_idx]

        return self.cur_R, self.cur_t

3. ORB-SLAM

ORB-SLAM κ°œμš”

ORB-SLAM μ•„ν‚€ν…μ²˜:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                                                                 β”‚
β”‚  ORB-SLAM: κ°€μž₯ 널리 μ‚¬μš©λ˜λŠ” Visual SLAM μ‹œμŠ€ν…œ                β”‚
β”‚                                                                 β”‚
β”‚  버전:                                                          β”‚
β”‚  - ORB-SLAM (2015): λ‹¨μ•ˆ                                        β”‚
β”‚  - ORB-SLAM2 (2017): λ‹¨μ•ˆ/μŠ€ν…Œλ ˆμ˜€/RGB-D                        β”‚
β”‚  - ORB-SLAM3 (2021): Visual-Inertial, 닀쀑 λ§΅                   β”‚
β”‚                                                                 β”‚
β”‚  3개의 병렬 μŠ€λ ˆλ“œ:                                             β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”‚
β”‚  β”‚                                                         β”‚    β”‚
β”‚  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”‚    β”‚
β”‚  β”‚  β”‚  Tracking   β”‚  β”‚Local Mappingβ”‚  β”‚Loop Closing β”‚     β”‚    β”‚
β”‚  β”‚  β”‚   Thread    β”‚  β”‚   Thread    β”‚  β”‚   Thread    β”‚     β”‚    β”‚
β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜     β”‚    β”‚
β”‚  β”‚         β”‚                β”‚                β”‚            β”‚    β”‚
β”‚  β”‚         β”‚    Keyframes   β”‚                β”‚            β”‚    β”‚
β”‚  β”‚         └───────────────▢│                β”‚            β”‚    β”‚
β”‚  β”‚                          β”‚    Keyframes   β”‚            β”‚    β”‚
β”‚  β”‚                          └───────────────▢│            β”‚    β”‚
β”‚  β”‚                                           β”‚            β”‚    β”‚
β”‚  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”β”‚            β”‚    β”‚
β”‚  β”‚  β”‚           Map (MapPoints)             β”‚β”‚            β”‚    β”‚
β”‚  β”‚  β”‚         & Covisibility Graph          β”‚β”‚            β”‚    β”‚
β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜β”‚            β”‚    β”‚
β”‚  β”‚                                           β”‚            β”‚    β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β”‚
β”‚                                                                 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Tracking μŠ€λ ˆλ“œ:
- λ§€ ν”„λ ˆμž„ 처리
- ORB νŠΉμ§• μΆ”μΆœ
- 이전 ν”„λ ˆμž„ λ˜λŠ” λ§΅κ³Ό λ§€μΉ­
- 초기 포즈 μΆ”μ •
- ν‚€ν”„λ ˆμž„ κ²°μ •

Local Mapping μŠ€λ ˆλ“œ:
- μƒˆ ν‚€ν”„λ ˆμž„ μ‚½μž…
- 졜근 MapPoint 컬링
- μƒˆ MapPoint 생성
- Local Bundle Adjustment
- 쀑볡 ν‚€ν”„λ ˆμž„ 제거

Loop Closing μŠ€λ ˆλ“œ:
- 루프 후보 κ²€μΆœ (DBoW2)
- 루프 검증 및 보정
- Essential Graph μ΅œμ ν™”
- μ „μ—­ Bundle Adjustment

ORB νŠΉμ§•κ³Ό Bag of Words

import cv2
import numpy as np

class ORBVocabulary:
    """ORB 기반 Bag of Words"""

    def __init__(self, num_words=1000):
        self.orb = cv2.ORB_create(1000)
        self.num_words = num_words
        self.vocabulary = None
        self.bf = cv2.BFMatcher(cv2.NORM_HAMMING)

    def train(self, images):
        """μ΄λ―Έμ§€λ‘œλΆ€ν„° vocabulary ν•™μŠ΅"""

        all_descriptors = []

        for img in images:
            gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
            _, desc = self.orb.detectAndCompute(gray, None)
            if desc is not None:
                all_descriptors.append(desc)

        all_desc = np.vstack(all_descriptors)

        # K-means ν΄λŸ¬μŠ€ν„°λ§
        criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER,
                   100, 0.2)
        _, labels, centers = cv2.kmeans(
            all_desc.astype(np.float32),
            self.num_words,
            None,
            criteria,
            10,
            cv2.KMEANS_RANDOM_CENTERS
        )

        self.vocabulary = centers.astype(np.uint8)
        print(f"Vocabulary 생성 μ™„λ£Œ: {self.num_words} words")

    def compute_bow(self, img):
        """μ΄λ―Έμ§€μ˜ BoW 벑터 계산"""

        gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
        _, desc = self.orb.detectAndCompute(gray, None)

        if desc is None:
            return np.zeros(self.num_words)

        # 각 λ””μŠ€ν¬λ¦½ν„°λ₯Ό κ°€μž₯ κ°€κΉŒμš΄ vocabulary word에 ν• λ‹Ή
        matches = self.bf.match(desc, self.vocabulary)

        bow = np.zeros(self.num_words)
        for m in matches:
            bow[m.trainIdx] += 1

        # μ •κ·œν™”
        bow = bow / (np.linalg.norm(bow) + 1e-6)

        return bow

    def compute_similarity(self, bow1, bow2):
        """두 BoW λ²‘ν„°μ˜ μœ μ‚¬λ„"""
        return np.dot(bow1, bow2)


class SimpleSLAM:
    """κ°„λ‹¨ν•œ SLAM μ‹œμŠ€ν…œ (ORB-SLAM 컨셉)"""

    def __init__(self, K):
        self.K = K
        self.orb = cv2.ORB_create(2000)
        self.bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)

        # λ§΅
        self.keyframes = []      # ν‚€ν”„λ ˆμž„ λͺ©λ‘
        self.map_points = []     # 3D 포인트
        self.poses = []          # ν‚€ν”„λ ˆμž„ 포즈

        # ν˜„μž¬ μƒνƒœ
        self.cur_R = np.eye(3)
        self.cur_t = np.zeros((3, 1))
        self.prev_frame = None
        self.prev_kp = None
        self.prev_desc = None

        # ν‚€ν”„λ ˆμž„ κΈ°μ€€
        self.kf_threshold = 30   # μ΅œμ†Œ λ§€μΉ­ 수

    def is_keyframe(self, num_matches, motion):
        """ν‚€ν”„λ ˆμž„ μ—¬λΆ€ κ²°μ •"""

        # κ°„λ‹¨ν•œ κΈ°μ€€: λ§€μΉ­ μˆ˜κ°€ μ κ±°λ‚˜ μ›€μ§μž„μ΄ 크면 ν‚€ν”„λ ˆμž„
        translation = np.linalg.norm(motion)

        if num_matches < self.kf_threshold or translation > 0.5:
            return True
        return False

    def add_keyframe(self, frame, kp, desc, pose):
        """ν‚€ν”„λ ˆμž„ μΆ”κ°€"""

        keyframe = {
            'frame': frame.copy(),
            'keypoints': kp,
            'descriptors': desc,
            'pose': pose.copy()
        }

        self.keyframes.append(keyframe)
        self.poses.append(pose)

        print(f"Keyframe μΆ”κ°€: 총 {len(self.keyframes)}개")

    def process_frame(self, frame):
        """ν”„λ ˆμž„ 처리 (Tracking)"""

        gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
        kp, desc = self.orb.detectAndCompute(gray, None)

        if self.prev_frame is None:
            # 첫 ν”„λ ˆμž„ β†’ ν‚€ν”„λ ˆμž„
            pose = {'R': np.eye(3), 't': np.zeros((3, 1))}
            self.add_keyframe(gray, kp, desc, pose)
            self.prev_frame = gray
            self.prev_kp = kp
            self.prev_desc = desc
            return self.cur_R, self.cur_t

        # 이전 ν”„λ ˆμž„κ³Ό λ§€μΉ­
        matches = self.bf.match(self.prev_desc, desc)
        matches = sorted(matches, key=lambda x: x.distance)[:500]

        if len(matches) >= 8:
            # 맀칭점 μΆ”μΆœ
            pts1 = np.float32([self.prev_kp[m.queryIdx].pt for m in matches])
            pts2 = np.float32([kp[m.trainIdx].pt for m in matches])

            # Essential Matrix둜 포즈 μΆ”μ •
            E, mask = cv2.findEssentialMat(pts1, pts2, self.K)
            _, R, t, mask = cv2.recoverPose(E, pts1, pts2, self.K)

            # 포즈 λˆ„μ 
            self.cur_t = self.cur_t + self.cur_R @ t
            self.cur_R = R @ self.cur_R

            # ν‚€ν”„λ ˆμž„ 체크
            if self.is_keyframe(len(matches), t):
                pose = {'R': self.cur_R.copy(), 't': self.cur_t.copy()}
                self.add_keyframe(gray, kp, desc, pose)

        # μƒνƒœ μ—…λ°μ΄νŠΈ
        self.prev_frame = gray
        self.prev_kp = kp
        self.prev_desc = desc

        return self.cur_R, self.cur_t

    def get_camera_trajectory(self):
        """카메라 ꢀ적 λ°˜ν™˜"""
        trajectory = []
        for pose in self.poses:
            R = pose['R']
            t = pose['t']
            # 카메라 μœ„μΉ˜ = -R^T * t
            pos = -R.T @ t
            trajectory.append(pos.ravel())
        return np.array(trajectory)

4. LiDAR SLAM

LiDAR SLAM κ°œμš”

LiDAR SLAM:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                                                                 β”‚
β”‚  LiDAR μ„Όμ„œ νŠΉμ§•:                                               β”‚
β”‚  - 360도 μŠ€μΊ”                                                   β”‚
β”‚  - μ •ν™•ν•œ 거리 μΈ‘μ •                                             β”‚
β”‚  - μ‘°λͺ… 쑰건에 강건                                             β”‚
β”‚  - ν’λΆ€ν•œ 3D 포인트 ν΄λΌμš°λ“œ                                    β”‚
β”‚                                                                 β”‚
β”‚  LiDAR μ’…λ₯˜:                                                    β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”‚
β”‚  β”‚ 2D LiDAR         β”‚ 평면 μŠ€μΊ”, μ €λ ΄, λ‘œλ΄‡ μ²­μ†ŒκΈ°        β”‚     β”‚
β”‚  β”‚ (예: RPLiDAR)    β”‚                                     β”‚     β”‚
β”‚  β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€     β”‚
β”‚  β”‚ 3D LiDAR         β”‚ 3D 포인트 ν΄λΌμš°λ“œ, μžμœ¨μ£Όν–‰       β”‚     β”‚
β”‚  β”‚ (예: Velodyne)   β”‚                                     β”‚     β”‚
β”‚  β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€     β”‚
β”‚  β”‚ Solid-State      β”‚ λ¬΄νšŒμ „, μ†Œν˜•, μ΅œμ‹  νŠΈλ Œλ“œ          β”‚     β”‚
β”‚  β”‚ (예: Livox)      β”‚                                     β”‚     β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β”‚
β”‚                                                                 β”‚
β”‚  μ£Όμš” μ•Œκ³ λ¦¬μ¦˜:                                                 β”‚
β”‚  - ICP (Iterative Closest Point)                               β”‚
β”‚  - NDT (Normal Distributions Transform)                        β”‚
β”‚  - LOAM (LiDAR Odometry and Mapping)                           β”‚
β”‚  - LeGO-LOAM (Lightweight Ground-Optimized)                    β”‚
β”‚  - Cartographer (Google)                                        β”‚
β”‚                                                                 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

ICP (Iterative Closest Point)

import numpy as np
from scipy.spatial import KDTree

def icp(source, target, max_iterations=50, tolerance=1e-6):
    """
    ICP μ•Œκ³ λ¦¬μ¦˜μœΌλ‘œ 두 포인트 ν΄λΌμš°λ“œ μ •ν•©

    Parameters:
        source: μ†ŒμŠ€ 포인트 ν΄λΌμš°λ“œ (N x 3)
        target: νƒ€κ²Ÿ 포인트 ν΄λΌμš°λ“œ (M x 3)

    Returns:
        R: νšŒμ „ ν–‰λ ¬ (3 x 3)
        t: 평행 이동 벑터 (3,)
        transformed: λ³€ν™˜λœ μ†ŒμŠ€ 포인트
    """

    src = source.copy()
    prev_error = float('inf')

    R_total = np.eye(3)
    t_total = np.zeros(3)

    # KD-Tree둜 효율적인 μ΅œκ·Όμ ‘ 탐색
    tree = KDTree(target)

    for i in range(max_iterations):
        # 1. μ΅œκ·Όμ ‘ λŒ€μ‘μ  μ°ΎκΈ°
        distances, indices = tree.query(src)
        correspondences = target[indices]

        # 2. λ³€ν™˜ μΆ”μ • (SVD)
        src_centroid = np.mean(src, axis=0)
        tgt_centroid = np.mean(correspondences, axis=0)

        src_centered = src - src_centroid
        tgt_centered = correspondences - tgt_centroid

        H = src_centered.T @ tgt_centered
        U, _, Vt = np.linalg.svd(H)
        R = Vt.T @ U.T

        # λ°˜μ‚¬ 보정
        if np.linalg.det(R) < 0:
            Vt[-1, :] *= -1
            R = Vt.T @ U.T

        t = tgt_centroid - R @ src_centroid

        # 3. λ³€ν™˜ 적용
        src = (R @ src.T).T + t

        # λˆ„μ  λ³€ν™˜
        R_total = R @ R_total
        t_total = R @ t_total + t

        # 4. 수렴 확인
        mean_error = np.mean(distances)
        if abs(prev_error - mean_error) < tolerance:
            print(f"ICP 수렴: {i+1} 반볡, 였차: {mean_error:.6f}")
            break
        prev_error = mean_error

    return R_total, t_total, src

class LiDARSLAM:
    """κ°„λ‹¨ν•œ 2D LiDAR SLAM"""

    def __init__(self, map_resolution=0.05):
        self.resolution = map_resolution
        self.pose = np.array([0.0, 0.0, 0.0])  # x, y, theta
        self.trajectory = [self.pose.copy()]

        # 점유 격자 맡
        self.map_size = 1000
        self.occupancy_map = np.ones((self.map_size, self.map_size)) * 0.5
        self.map_origin = np.array([self.map_size // 2, self.map_size // 2])

    def scan_to_points(self, scan_ranges, scan_angles):
        """μŠ€μΊ” 데이터λ₯Ό 2D 포인트둜 λ³€ν™˜"""

        valid = (scan_ranges > 0.1) & (scan_ranges < 30.0)
        ranges = scan_ranges[valid]
        angles = scan_angles[valid]

        x = ranges * np.cos(angles)
        y = ranges * np.sin(angles)

        return np.column_stack([x, y])

    def transform_points(self, points, pose):
        """포인트λ₯Ό μ›”λ“œ μ’Œν‘œλ‘œ λ³€ν™˜"""

        x, y, theta = pose
        R = np.array([
            [np.cos(theta), -np.sin(theta)],
            [np.sin(theta), np.cos(theta)]
        ])

        transformed = (R @ points.T).T + np.array([x, y])
        return transformed

    def point_to_grid(self, points):
        """포인트λ₯Ό κ·Έλ¦¬λ“œ μ’Œν‘œλ‘œ λ³€ν™˜"""

        grid_x = (points[:, 0] / self.resolution + self.map_origin[0]).astype(int)
        grid_y = (points[:, 1] / self.resolution + self.map_origin[1]).astype(int)

        # λ§΅ λ²”μœ„ λ‚΄λ‘œ μ œν•œ
        valid = (grid_x >= 0) & (grid_x < self.map_size) & \
                (grid_y >= 0) & (grid_y < self.map_size)

        return grid_x[valid], grid_y[valid], valid

    def update_map(self, scan_points, pose):
        """점유 격자 λ§΅ μ—…λ°μ΄νŠΈ"""

        world_points = self.transform_points(scan_points, pose)
        gx, gy, valid = self.point_to_grid(world_points)

        # 점유 ν™•λ₯  μ—…λ°μ΄νŠΈ (둜그 였즈)
        self.occupancy_map[gy, gx] = np.clip(
            self.occupancy_map[gy, gx] + 0.1, 0, 1
        )

    def match_scan(self, current_points, previous_points):
        """μŠ€μΊ” 맀칭으둜 μƒλŒ€ 이동 μΆ”μ •"""

        if len(previous_points) < 10 or len(current_points) < 10:
            return np.array([0, 0, 0])

        # ICP 적용
        R, t, _ = icp(current_points, previous_points)

        # 2Dμ—μ„œ theta μΆ”μΆœ
        theta = np.arctan2(R[1, 0], R[0, 0])

        return np.array([t[0], t[1], theta])

    def process_scan(self, scan_ranges, scan_angles, prev_scan=None):
        """μŠ€μΊ” 처리"""

        current_points = self.scan_to_points(scan_ranges, scan_angles)

        if prev_scan is not None:
            prev_points = self.scan_to_points(prev_scan[0], prev_scan[1])

            # μŠ€μΊ” λ§€μΉ­
            delta_pose = self.match_scan(current_points, prev_points)

            # 포즈 μ—…λ°μ΄νŠΈ
            self.pose[2] += delta_pose[2]
            R = np.array([
                [np.cos(self.pose[2]), -np.sin(self.pose[2])],
                [np.sin(self.pose[2]), np.cos(self.pose[2])]
            ])
            self.pose[:2] += R @ delta_pose[:2]

        # λ§΅ μ—…λ°μ΄νŠΈ
        self.update_map(current_points, self.pose)

        # ꢀ적 μ €μž₯
        self.trajectory.append(self.pose.copy())

        return self.pose

    def get_occupancy_map(self):
        """점유 λ§΅ λ°˜ν™˜"""
        return self.occupancy_map

    def get_trajectory(self):
        """ꢀ적 λ°˜ν™˜"""
        return np.array(self.trajectory)

5. Loop Closure

Loop Closure κ°œλ…

Loop Closure (루프 폐합):
이전에 λ°©λ¬Έν•œ μž₯μ†Œλ₯Ό μž¬μΈμ‹ν•˜μ—¬ λˆ„μ  였차 보정

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                                                                 β”‚
β”‚  문제: Drift (λˆ„μ  였차)                                        β”‚
β”‚                                                                 β”‚
β”‚       μ‹€μ œ 경둜        μΆ”μ • 경둜 (drift 있음)                   β”‚
β”‚       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”                              β”‚
β”‚       β”‚         β”‚      β”‚         β•²                              β”‚
β”‚       β”‚         β”‚      β”‚          β•²                             β”‚
β”‚       β”‚         β”‚      β”‚           β•²                            β”‚
β”‚       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜      └────────────╲                           β”‚
β”‚       (폐곑선)          (μ—΄λ¦° 곑선)                             β”‚
β”‚                                                                 β”‚
β”‚  ν•΄κ²°: Loop Closure                                             β”‚
β”‚       1. ν˜„μž¬ μœ„μΉ˜κ°€ 이전에 λ°©λ¬Έν•œ 곳인지 탐지                  β”‚
β”‚       2. 루프 μ œμ•½ 쑰건 μΆ”κ°€                                    β”‚
β”‚       3. 포즈 κ·Έλž˜ν”„ μ΅œμ ν™”                                     β”‚
β”‚                                                                 β”‚
β”‚       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”                                               β”‚
β”‚       β”‚    ●────●  ← 루프 탐지                                  β”‚
β”‚       β”‚    β”‚    β”‚                                               β”‚
β”‚       β”‚    β”‚    β”‚  ← κ·Έλž˜ν”„ μ΅œμ ν™”                              β”‚
β”‚       β”‚    ●────●                                               β”‚
β”‚       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                                               β”‚
β”‚       (λ³΄μ •λœ 경둜)                                             β”‚
β”‚                                                                 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Loop Closure κ΅¬ν˜„

import cv2
import numpy as np
from collections import deque

class LoopClosureDetector:
    """Bag of Words 기반 루프 ν΄λ‘œμ € 탐지"""

    def __init__(self, vocabulary_size=1000, min_score=0.3):
        self.orb = cv2.ORB_create(2000)
        self.bf = cv2.BFMatcher(cv2.NORM_HAMMING)

        self.vocabulary = None
        self.vocabulary_size = vocabulary_size
        self.min_score = min_score

        # ν‚€ν”„λ ˆμž„ λ°μ΄ν„°λ² μ΄μŠ€
        self.keyframe_bows = []
        self.keyframe_descs = []
        self.keyframe_kps = []

        # 졜근 N개 ν‚€ν”„λ ˆμž„μ€ 루프 ν›„λ³΄μ—μ„œ μ œμ™Έ
        self.temporal_window = 30

    def build_vocabulary(self, training_images):
        """Vocabulary ꡬ좕"""

        all_descriptors = []

        for img in training_images:
            gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
            _, desc = self.orb.detectAndCompute(gray, None)
            if desc is not None:
                all_descriptors.append(desc)

        all_desc = np.vstack(all_descriptors).astype(np.float32)

        # K-means
        criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER,
                   100, 0.2)
        _, _, self.vocabulary = cv2.kmeans(
            all_desc, self.vocabulary_size, None,
            criteria, 10, cv2.KMEANS_RANDOM_CENTERS
        )

        self.vocabulary = self.vocabulary.astype(np.uint8)

    def compute_bow(self, descriptors):
        """BoW 벑터 계산"""

        if self.vocabulary is None or descriptors is None:
            return None

        matches = self.bf.match(descriptors, self.vocabulary)

        bow = np.zeros(self.vocabulary_size)
        for m in matches:
            bow[m.trainIdx] += 1

        # L2 μ •κ·œν™”
        norm = np.linalg.norm(bow)
        if norm > 0:
            bow = bow / norm

        return bow

    def add_keyframe(self, frame):
        """ν‚€ν”„λ ˆμž„ μΆ”κ°€"""

        gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
        kp, desc = self.orb.detectAndCompute(gray, None)

        if desc is None:
            return -1

        bow = self.compute_bow(desc)

        self.keyframe_bows.append(bow)
        self.keyframe_descs.append(desc)
        self.keyframe_kps.append(kp)

        return len(self.keyframe_bows) - 1

    def detect_loop(self, query_idx):
        """루프 후보 탐지"""

        if query_idx < self.temporal_window + 1:
            return None, 0

        query_bow = self.keyframe_bows[query_idx]

        best_match = -1
        best_score = 0

        # μ‹œκ°„μ μœΌλ‘œ 멀리 λ–¨μ–΄μ§„ ν‚€ν”„λ ˆμž„λ§Œ 검색
        for i in range(query_idx - self.temporal_window):
            score = np.dot(query_bow, self.keyframe_bows[i])

            if score > best_score and score > self.min_score:
                best_score = score
                best_match = i

        if best_match >= 0:
            return best_match, best_score

        return None, 0

    def verify_loop(self, query_idx, candidate_idx, min_inliers=50):
        """κΈ°ν•˜ν•™μ  κ²€μ¦μœΌλ‘œ 루프 확인"""

        desc1 = self.keyframe_descs[query_idx]
        desc2 = self.keyframe_descs[candidate_idx]
        kp1 = self.keyframe_kps[query_idx]
        kp2 = self.keyframe_kps[candidate_idx]

        # νŠΉμ§•μ  λ§€μΉ­
        matches = self.bf.knnMatch(desc1, desc2, k=2)

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

        if len(good_matches) < 8:
            return False, None

        # Fundamental Matrix둜 κΈ°ν•˜ν•™μ  검증
        pts1 = np.float32([kp1[m.queryIdx].pt for m in good_matches])
        pts2 = np.float32([kp2[m.trainIdx].pt for m in good_matches])

        F, mask = cv2.findFundamentalMat(pts1, pts2, cv2.FM_RANSAC)

        if mask is None:
            return False, None

        num_inliers = np.sum(mask)

        if num_inliers >= min_inliers:
            return True, {
                'query_idx': query_idx,
                'match_idx': candidate_idx,
                'inliers': num_inliers,
                'pts1': pts1[mask.ravel() == 1],
                'pts2': pts2[mask.ravel() == 1]
            }

        return False, None


class PoseGraphOptimizer:
    """κ°„λ‹¨ν•œ 포즈 κ·Έλž˜ν”„ μ΅œμ ν™”"""

    def __init__(self):
        self.poses = []         # λ…Έλ“œ (포즈)
        self.edges = []         # μ—£μ§€ (μƒλŒ€ λ³€ν™˜)
        self.loop_constraints = []  # 루프 μ œμ•½

    def add_pose(self, pose):
        """포즈 λ…Έλ“œ μΆ”κ°€"""
        self.poses.append(pose.copy())
        return len(self.poses) - 1

    def add_odometry_edge(self, i, j, relative_pose, info_matrix=None):
        """μ˜€λ„λ©”νŠΈλ¦¬ μ—£μ§€ μΆ”κ°€"""

        if info_matrix is None:
            info_matrix = np.eye(3)

        self.edges.append({
            'from': i,
            'to': j,
            'measurement': relative_pose,
            'info': info_matrix
        })

    def add_loop_constraint(self, i, j, relative_pose, info_matrix=None):
        """루프 μ œμ•½ μΆ”κ°€"""

        if info_matrix is None:
            # 루프 μ œμ•½μ€ 높은 κ°€μ€‘μΉ˜
            info_matrix = np.eye(3) * 100

        self.loop_constraints.append({
            'from': i,
            'to': j,
            'measurement': relative_pose,
            'info': info_matrix
        })

    def optimize(self, num_iterations=10):
        """κ·Έλž˜ν”„ μ΅œμ ν™” (Gauss-Newton)"""

        # κ°„λ‹¨ν•œ κ΅¬ν˜„ (μ‹€μ œλ‘œλŠ” g2o, Ceres λ“± μ‚¬μš©)
        print("포즈 κ·Έλž˜ν”„ μ΅œμ ν™”λŠ” g2o λ“± μ „λ¬Έ 라이브러리 ꢌμž₯")

        # 루프 μ œμ•½μ„ μ΄μš©ν•œ κ°„λ‹¨ν•œ 보정
        for constraint in self.loop_constraints:
            i = constraint['from']
            j = constraint['to']

            # λˆ„μ  λ“œλ¦¬ν”„νŠΈ 계산
            drift = self.poses[j][:2] - self.poses[i][:2]
            drift -= constraint['measurement'][:2]

            # μ„ ν˜• λ³΄κ°„μœΌλ‘œ λ“œλ¦¬ν”„νŠΈ λΆ„λ°°
            for k in range(i, j + 1):
                alpha = (k - i) / (j - i) if j > i else 0
                self.poses[k][:2] -= alpha * drift

        return self.poses

6. SLAM κ΅¬ν˜„ μ‹€μŠ΅

κ°„λ‹¨ν•œ SLAM μ‹œμŠ€ν…œ

import cv2
import numpy as np

class SimpleVSLAM:
    """κ°„λ‹¨ν•œ Visual SLAM μ‹œμŠ€ν…œ"""

    def __init__(self, K):
        self.K = K

        # λͺ¨λ“ˆ
        self.vo = MonocularVO(K)
        self.loop_detector = LoopClosureDetector()
        self.pose_graph = PoseGraphOptimizer()

        # μƒνƒœ
        self.frame_count = 0
        self.keyframe_interval = 10

    def process_frame(self, frame):
        """ν”„λ ˆμž„ 처리"""

        self.frame_count += 1

        # Visual Odometry
        R, t = self.vo.process_frame(frame)

        # ν‚€ν”„λ ˆμž„ μΆ”κ°€
        if self.frame_count % self.keyframe_interval == 0:
            kf_idx = self.loop_detector.add_keyframe(frame)

            # 포즈 κ·Έλž˜ν”„μ— λ…Έλ“œ μΆ”κ°€
            pose = np.array([t[0, 0], t[1, 0], 0])  # 2D 근사
            node_idx = self.pose_graph.add_pose(pose)

            # 이전 ν‚€ν”„λ ˆμž„κ³Ό μ—£μ§€ μ—°κ²°
            if node_idx > 0:
                prev_pose = self.pose_graph.poses[node_idx - 1]
                relative = pose - prev_pose
                self.pose_graph.add_odometry_edge(
                    node_idx - 1, node_idx, relative
                )

            # 루프 탐지
            if kf_idx > 30:  # μΆ©λΆ„ν•œ ν‚€ν”„λ ˆμž„ ν›„
                candidate, score = self.loop_detector.detect_loop(kf_idx)

                if candidate is not None:
                    verified, loop_info = self.loop_detector.verify_loop(
                        kf_idx, candidate
                    )

                    if verified:
                        print(f"Loop detected: {kf_idx} -> {candidate}")

                        # 루프 μ œμ•½ μΆ”κ°€
                        relative = pose - self.pose_graph.poses[candidate]
                        self.pose_graph.add_loop_constraint(
                            candidate, node_idx, relative
                        )

                        # μ΅œμ ν™”
                        self.pose_graph.optimize()

        return R, t

    def get_map(self):
        """λ§΅ λ°˜ν™˜"""
        return self.vo.get_trajectory()

    def get_optimized_trajectory(self):
        """μ΅œμ ν™”λœ ꢀ적 λ°˜ν™˜"""
        return np.array(self.pose_graph.poses)

μ‹œκ°ν™”

import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D

def visualize_slam_result(trajectory, loop_closures=None):
    """SLAM κ²°κ³Ό μ‹œκ°ν™”"""

    fig = plt.figure(figsize=(12, 5))

    # 2D ꢀ적
    ax1 = fig.add_subplot(121)
    ax1.plot(trajectory[:, 0], trajectory[:, 1], 'b-', linewidth=1)
    ax1.scatter(trajectory[0, 0], trajectory[0, 1],
               c='green', s=100, marker='o', label='Start')
    ax1.scatter(trajectory[-1, 0], trajectory[-1, 1],
               c='red', s=100, marker='x', label='End')

    if loop_closures:
        for lc in loop_closures:
            i, j = lc['from'], lc['to']
            ax1.plot([trajectory[i, 0], trajectory[j, 0]],
                    [trajectory[i, 1], trajectory[j, 1]],
                    'g--', linewidth=2, alpha=0.5)

    ax1.set_xlabel('X')
    ax1.set_ylabel('Y')
    ax1.set_title('2D Trajectory')
    ax1.legend()
    ax1.axis('equal')
    ax1.grid(True)

    # 3D ꢀ적
    ax2 = fig.add_subplot(122, projection='3d')
    ax2.plot(trajectory[:, 0], trajectory[:, 1], trajectory[:, 2],
            'b-', linewidth=1)

    ax2.set_xlabel('X')
    ax2.set_ylabel('Y')
    ax2.set_zlabel('Z')
    ax2.set_title('3D Trajectory')

    plt.tight_layout()
    plt.show()

def visualize_occupancy_map(occupancy_map, trajectory=None):
    """점유 λ§΅ μ‹œκ°ν™”"""

    plt.figure(figsize=(10, 10))

    # λ§΅ ν‘œμ‹œ
    plt.imshow(occupancy_map, cmap='gray', origin='lower')

    # ꢀ적 μ˜€λ²„λ ˆμ΄
    if trajectory is not None:
        # λ§΅ μ’Œν‘œλ‘œ λ³€ν™˜
        map_center = occupancy_map.shape[0] // 2
        resolution = 0.05
        traj_map = trajectory / resolution + map_center

        plt.plot(traj_map[:, 0], traj_map[:, 1], 'r-', linewidth=2)
        plt.scatter(traj_map[0, 0], traj_map[0, 1], c='green', s=100)
        plt.scatter(traj_map[-1, 0], traj_map[-1, 1], c='blue', s=100)

    plt.title('Occupancy Grid Map')
    plt.xlabel('X')
    plt.ylabel('Y')
    plt.colorbar(label='Occupancy Probability')
    plt.show()

7. μ—°μŠ΅ 문제

문제 1: Visual Odometry κ΅¬ν˜„

λ‹¨μ•ˆ Visual Odometryλ₯Ό κ΅¬ν˜„ν•˜μ„Έμš”.

μš”κ΅¬μ‚¬ν•­: - ORB νŠΉμ§• κ²€μΆœ - κ΄‘ν•™ 흐름 λ˜λŠ” λ””μŠ€ν¬λ¦½ν„° λ§€μΉ­ - Essential Matrix둜 포즈 μΆ”μ • - ꢀ적 μ‹œκ°ν™”

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

# 포즈 λˆ„μ 
cur_t = cur_t + cur_R @ t
cur_R = R @ cur_R

문제 2: 루프 ν΄λ‘œμ € 탐지

BoW 기반 루프 ν΄λ‘œμ €λ₯Ό κ΅¬ν˜„ν•˜μ„Έμš”.

μš”κ΅¬μ‚¬ν•­: - ORB vocabulary ꡬ좕 - BoW 벑터 계산 - μœ μ‚¬λ„ 기반 후보 탐지 - κΈ°ν•˜ν•™μ  검증

힌트
# BoW μœ μ‚¬λ„
score = np.dot(bow1, bow2)

# κΈ°ν•˜ν•™μ  검증
F, mask = cv2.findFundamentalMat(pts1, pts2, cv2.FM_RANSAC)
inliers = np.sum(mask)

문제 3: ICP κ΅¬ν˜„

ICP μ•Œκ³ λ¦¬μ¦˜μ„ κ΅¬ν˜„ν•˜μ„Έμš”.

μš”κ΅¬μ‚¬ν•­: - μ΅œκ·Όμ ‘ λŒ€μ‘μ  검색 - SVD둜 λ³€ν™˜ μΆ”μ • - 반볡 μ΅œμ ν™” - 수렴 쑰건

힌트
# SVD둜 R, t 계산
H = src_centered.T @ tgt_centered
U, _, Vt = np.linalg.svd(H)
R = Vt.T @ U.T
t = tgt_centroid - R @ src_centroid

문제 4: 점유 격자 맡

LiDAR λ°μ΄ν„°λ‘œ 점유 격자 맡을 μƒμ„±ν•˜μ„Έμš”.

μš”κ΅¬μ‚¬ν•­: - μŠ€μΊ” 데이터λ₯Ό 포인트둜 λ³€ν™˜ - 격자 μ’Œν‘œ λ³€ν™˜ - 점유 ν™•λ₯  μ—…λ°μ΄νŠΈ - λ§΅ μ‹œκ°ν™”

힌트
# 둜그 였즈 μ—…λ°μ΄νŠΈ
log_odds = np.log(p / (1 - p))
log_odds[occupied] += 0.5
log_odds[free] -= 0.2
p = 1 / (1 + np.exp(-log_odds))

문제 5: μ™„μ „ν•œ SLAM μ‹œμŠ€ν…œ

VO, 루프 ν΄λ‘œμ €, 맡핑을 ν†΅ν•©ν•œ SLAM을 κ΅¬ν˜„ν•˜μ„Έμš”.

μš”κ΅¬μ‚¬ν•­: - ν‚€ν”„λ ˆμž„ 관리 - 루프 탐지 및 검증 - 포즈 κ·Έλž˜ν”„ μ΅œμ ν™” - 3D λ§΅ 생성

힌트
# 톡합 μ‹œμŠ€ν…œ
class SLAM:
    def process(self, frame):
        # 1. νŠΈλž˜ν‚Ή
        pose = self.track(frame)

        # 2. ν‚€ν”„λ ˆμž„μ΄λ©΄ λ§΅ μ—…λ°μ΄νŠΈ
        if self.is_keyframe():
            self.local_mapping()

            # 3. 루프 탐지
            if self.detect_loop():
                self.optimize_graph()

λ‹€μŒ 단계

  • μ‹€μ œ SLAM 라이브러리 μ‚¬μš© (ORB-SLAM3, RTAB-Map)
  • ROS 연동
  • Visual-Inertial SLAM
  • λ”₯λŸ¬λ‹ 기반 SLAM

참고 자료

to navigate between lessons