포스트

삼각측량과 번들 조정 — 점을 되살리고, 전부 함께 다듬는다

삼각측량과 번들 조정 — 점을 되살리고, 전부 함께 다듬는다

서론

지난 글에서 사진 두 장으로부터 카메라 사이의 상대 자세 $(R, t)$를 얻는 데까지 갔습니다. 그런데 자세만 알아서는 지도가 되지 않습니다. 남은 일이 둘 있습니다.

  1. 점을 되살리기. 두 이미지에서 같은 점을 봤고 카메라 자세도 안다면, 그 점의 3차원 위치를 구할 수 있어야 합니다. → 삼각측량(triangulation)
  2. 전부 함께 다듬기. 두 장씩 순서대로 처리하면 오차가 쌓입니다. 모든 카메라와 모든 점을 한꺼번에 최적화해야 합니다. → 번들 조정(bundle adjustment)

이 두 단계가 SfM과 SLAM의 뒤쪽 절반, 흔히 말하는 백엔드입니다. COLMAP이 3DGS에 넘겨주는 포즈와 점군도 결국 여기서 나옵니다.


1. 삼각측량 — 두 광선의 교점

기하는 단순합니다. 카메라 중심에서 이미지 점을 지나는 광선을 두 개 그리고, 그 교점을 찾으면 끝입니다.

문제는 현실에서 두 광선이 만나지 않는다는 것입니다. 특징점 위치에 0.5픽셀만 오차가 있어도 두 광선은 3차원에서 살짝 어긋나 지나갑니다(skew lines). 그래서 “교점을 구한다”가 아니라 “어떤 의미에서 가장 그럴듯한 점을 고른다“가 됩니다.

가장 널리 쓰이는 출발점은 DLT(Direct Linear Transform) 입니다. 투영식 $x \simeq PX$ 에서 $\simeq$ 를 없애기 위해 외적을 씁니다. $x \times (PX) = 0$ 을 풀어쓰면 이미지 한 장에서 독립인 식이 두 개 나옵니다.

\[\begin{bmatrix} u\,p_3^\top - p_1^\top \\ v\,p_3^\top - p_2^\top \\ u'\,q_3^\top - q_1^\top \\ v'\,q_3^\top - q_2^\top \end{bmatrix} X = 0\]

$p_k^\top$, $q_k^\top$ 는 각 투영행렬의 $k$번째 행입니다. 미지수는 동차좌표 $X$ 하나(4개 성분), 식은 4개. 지난 글의 8점 알고리즘과 판박이로, SVD의 최소 특이벡터를 취하면 됩니다.

1
2
3
4
5
6
7
8
9
10
import numpy as np

def triangulate_dlt(P1, P2, x1, x2):
    """두 투영행렬과 대응점으로 3차원 점을 복원한다."""
    A = np.array([x1[0] * P1[2] - P1[0],
                  x1[1] * P1[2] - P1[1],
                  x2[0] * P2[2] - P2[0],
                  x2[1] * P2[2] - P2[1]])
    X = np.linalg.svd(A)[2][-1]     # A X = 0 의 최소제곱 해
    return X[:3] / X[3]             # 동차좌표를 3차원으로

합성 데이터로 확인해 보면, 노이즈가 없을 때 복원 오차는 $10^{-14}$ 수준(수치 오차)이고, 0.5픽셀 노이즈를 넣으면 깊이 5~9인 장면에서 평균 0.028만큼 어긋납니다. 열 줄짜리 함수치고는 잘 동작합니다.

복원한 뒤에는 cheirality 검사를 반드시 해야 합니다. 복원된 점이 두 카메라 앞쪽에 있는지 확인하는 것인데, 사영기하는 카메라 뒤의 점도 아무렇지 않게 허용하기 때문입니다.

무엇을 최소화하고 있는가

여기에 함정이 하나 있습니다. DLT가 최소화하는 것은 $\lVert AX \rVert$, 즉 대수적 오차입니다. 이건 우리가 정말 줄이고 싶은 양이 아닙니다. 우리가 원하는 건 재투영 오차 — 복원한 점을 다시 이미지에 투영했을 때 실제 관측점과 얼마나 떨어지는가입니다.

\[\min_{X} \; d\bigl(\pi(P, X),\, x\bigr)^2 + d\bigl(\pi(P', X),\, x'\bigr)^2\]

이쪽이 통계적으로 정당합니다. 특징점 검출 오차가 이미지 위에서 등방 가우시안이라면, 재투영 오차의 제곱합을 최소화하는 것이 곧 최대가능도 추정이기 때문입니다. 두 시점에 대해서는 이 문제의 정확한 해를 6차 다항식의 근으로 구하는 방법이 알려져 있고,1 실전에서는 보통 DLT 결과를 초기값 삼아 비선형 최적화로 몇 번 다듬습니다.

이 “대수적 오차로 시작해서 재투영 오차로 마무리한다“는 패턴은 이 분야 어디에나 나옵니다. 번들 조정이 바로 그 최종판입니다.


2. 베이스라인이 전부다

삼각측량의 정확도는 알고리즘보다 기하 배치가 좌우합니다.

베이스라인 길이에 따른 깊이 불확실성 같은 각도 오차라도 두 시점이 가까우면 불확실 영역이 시선 방향으로 길게 늘어난다. 베이스라인을 6배 늘리자 깊이 불확실성이 약 1/7로 줄었다(각도 오차는 보이도록 과장).

두 광선이 얕은 각도로 만나면, 각도가 조금만 흔들려도 교점이 시선 방향으로 크게 미끄러집니다. 깊이 불확실성은 대략 이렇게 움직입니다.

\[\Delta z \;\sim\; \frac{z^2}{f\,b}\,\Delta u\]

$b$는 베이스라인, $z$는 깊이입니다. 실무적으로 중요한 결론이 두 개 나옵니다.

  • 깊이의 제곱에 비례해 나빠진다. 먼 점일수록 깊이는 못 믿습니다. 그래서 SLAM에서는 멀리 있는 점을 역깊이(inverse depth) 로 다루거나, 방향 정보만 쓰고 깊이는 포기하기도 합니다.
  • 베이스라인에 반비례한다. 카메라가 제자리에서 회전만 하면 $b = 0$ 이라 삼각측량 자체가 불가능합니다. 단안 SLAM이 초기화할 때 “옆으로 좀 움직여 주세요”를 요구하는 이유가 이것입니다.

3. 오차는 쌓인다

이제 두 시점으로 점을 복원할 수 있게 됐으니, 순진하게 이어 붙여 봅시다. 1–2번 프레임으로 초기 지도를 만들고, 그 점들을 이용해 3번 카메라 자세를 구하고(PnP), 새로 보이는 점을 삼각측량하고, 4번으로 넘어가고….

각 단계는 이전 단계의 결과를 참값처럼 씁니다. 그런데 이전 단계에는 오차가 있었습니다. 그 오차 위에서 다음 자세를 추정하니 오차가 얹히고, 그 위에 또 얹힙니다. 100프레임쯤 가면 궤적이 눈에 띄게 휘어 있습니다. 이것이 드리프트(drift) 입니다.

근본 원인은 명확합니다. 한 번 정한 값을 다시 건드리지 않기 때문입니다. 처방도 명확합니다.

카메라 자세도 3차원 점도 전부 동시에 미지수로 놓고, 모든 관측을 한꺼번에 설명하도록 함께 조정한다.


4. 번들 조정 — 재투영 오차의 총합 최소화

정의는 한 줄입니다.

\[\min_{\{R_j, t_j\},\, \{X_i\}} \;\sum_{i}\sum_{j} v_{ij}\, \bigl\lVert \pi(R_j, t_j, X_i) - x_{ij} \bigr\rVert^2\]

$\pi$는 투영 함수, $x_{ij}$는 $j$번 카메라가 관측한 $i$번 점의 픽셀 좌표, $v_{ij}$는 그 점이 그 카메라에 보였는지를 나타내는 0/1 값입니다. 이름은 각 3차원 점에서 카메라들로 뻗어 나가는 광선 다발(bundle) 을 조정한다는 데서 왔습니다.2

주목할 점 두 가지입니다.

  • 목적함수가 재투영 오차뿐이다. 앞에서 본 그 기준 그대로입니다. 관측 노이즈가 등방 가우시안이면 이 해가 곧 MLE입니다.
  • 비선형 최소제곱이다. $\pi$ 안에 회전과 원근 나눗셈이 들어 있어 비선형입니다. 그래서 Gauss-Newton이나 Levenberg-Marquardt로 풉니다. 잔차 벡터를 $r$, 야코비안을 $J$라 하면 매 반복마다 이걸 풉니다.
\[(J^\top J + \lambda D)\,\delta = -\,J^\top r\]

문제는 크기입니다. 카메라 1,000대와 점 100,000개면 미지수가 30만 개가 넘습니다. $J^\top J$ 는 $300{,}000 \times 300{,}000$ 행렬이고, 이걸 그냥 분해하는 건 불가능합니다.


5. 왜 풀 수 있나 — 희소성과 Schur 보수

여기서 구조가 구원합니다. 관측 하나는 카메라 한 대와 점 하나에만 의존합니다. 나머지 수십만 개 파라미터에 대한 미분은 전부 0입니다. 즉 $J$는 극도로 희소하고, $J^\top J$ 는 아주 특별한 모양을 갖습니다.

번들 조정의 희소 구조 카메라 9대·점 80개·관측 558개인 작은 문제의 실제 구조. 왼쪽: 야코비안. 가운데: $J^\top J$ 의 화살촉(arrowhead) 구조 — 오른쪽 아래 점 블록은 3×3 블록대각이다. 오른쪽: 점을 소거하고 남은 54×54 카메라 시스템.

미지수를 카메라 묶음과 점 묶음으로 나누면 정규방정식이 이렇게 쪼개집니다.

\[\begin{bmatrix} B & E \\ E^\top & C \end{bmatrix} \begin{bmatrix} \delta_{\text{cam}} \\ \delta_{\text{pt}} \end{bmatrix} = \begin{bmatrix} v \\ w \end{bmatrix}\]

핵심은 오른쪽 아래 $C$ 입니다. 서로 다른 두 3차원 점은 직접 연결되지 않기 때문에(둘 다 카메라를 통해서만 얽힙니다) $C$ 는 점마다 $3 \times 3$ 짜리 블록대각 행렬입니다. 블록대각 행렬의 역행렬은 블록마다 따로 구하면 되므로 사실상 공짜입니다.

그래서 점 변수를 대수적으로 소거해 버립니다. 이것이 Schur 보수(Schur complement) 입니다.

\[\bigl(B - E\,C^{-1} E^\top\bigr)\,\delta_{\text{cam}} \;=\; v - E\,C^{-1} w\]

남는 것은 카메라 크기의 시스템뿐입니다. 위 그림의 예에서 294×294가 54×54로 줄었고, 실제 SfM에서는 30만 차원이 6,000차원으로 줄어듭니다. 카메라를 먼저 풀고 나면 점 갱신량은 블록별로 대입해서 바로 얻습니다.

이 한 가지 트릭이 번들 조정을 실용적으로 만들었습니다. Ceres Solver, g2o, GTSAM 같은 라이브러리가 하는 일의 상당 부분이 이 구조를 잘 활용하는 것입니다.


6. 실제로 돌려보기

작은 문제를 만들어 확인해 봅니다. 카메라 9대를 호를 그리며 배치하고, 3차원 점 80개를 뿌리고, 관측 558개에 0.4픽셀 가우시안 노이즈를 섞었습니다. 그리고 초기값으로는 참값에 회전 약 1.7°, 위치·점 좌표에 0.15만큼 섭동을 줬습니다.

파라미터는 카메라 $9 \times 6$ + 점 $80 \times 3$ = 294개, 잔차는 1,116개입니다.

1
2
3
4
5
6
7
8
9
10
from scipy.optimize import least_squares

def residual(x):
    cams = x[:N_CAM * 6].reshape(N_CAM, 6)   # 축-각 3 + 이동 3
    pts = x[N_CAM * 6:].reshape(N_PT, 3)
    return (project(pts[pt_idx], cams[cam_idx]) - obs).ravel()

res = least_squares(residual, x0,
                    jac_sparsity=sparsity_pattern,   # 이게 없으면 현실적으로 불가능
                    method="trf", x_scale="jac")

jac_sparsity가 이 코드의 핵심입니다. 희소 패턴을 알려 주면 수치 미분이 파라미터 294개를 하나씩 건드리는 대신 몇 번의 평가로 야코비안 전체를 채웁니다. 앞 절에서 말한 희소성을 그대로 써먹는 것입니다.

번들 조정 수렴 곡선 RMS 재투영 오차가 34.8픽셀에서 세 번의 반복 만에 0.48픽셀로 떨어진다. 점선은 주입한 관측 노이즈 수준.

결과를 정리하면 이렇습니다.

 
초기 RMS 재투영 오차34.81 px
최종 RMS 재투영 오차0.480 px
수렴까지 반복 횟수3회

마지막 값이 제대로 수렴한 것인지 검산해 볼 수 있습니다. 축마다 표준편차 0.4픽셀 노이즈를 넣었으므로 2차원 잔차의 RMS 기대값은 $0.4\sqrt{2} \approx 0.566$ 픽셀입니다. 여기에 자유도 보정 $\sqrt{1 - 294/1116} \approx 0.859$ 를 곱하면 0.486픽셀. 실측값 0.480픽셀과 사실상 일치합니다. 노이즈가 허용하는 한계까지 맞춘 것이지, 그 이상으로 데이터를 과적합하지도 미달하지도 않았다는 뜻입니다.

반복 횟수가 세 번뿐인 것도 눈여겨볼 만합니다. 좋은 초기값에서 출발하면 Gauss-Newton 계열은 매우 빠르게 수렴합니다. 뒤집어 말하면 번들 조정은 국소 최적화이고, 초기값이 나쁘면 엉뚱한 곳에 멈춥니다. 그래서 앞단의 에피폴라 기하·PnP·삼각측량이 여전히 필요합니다.


7. 실전에서 반드시 붙는 것들

교과서 수식과 돌아가는 시스템 사이의 간격은 대부분 다음 넷입니다.

① 로버스트 손실. 특징점 매칭에는 반드시 잘못된 짝이 섞이고, 제곱 손실은 큰 오차 하나에 전체가 끌려갑니다. Huber나 Cauchy 손실로 바꿔 아웃라이어의 영향력에 상한을 씌웁니다.

② 게이지 자유도. 지도 전체를 회전·평행이동·확대해도 재투영 오차는 완전히 똑같습니다. 즉 해가 7차원 다양체를 이루고 $J^\top J$ 는 특이행렬입니다. 보통 첫 카메라를 원점에 고정하고 두 카메라 사이 거리를 1로 못 박아 이 자유도를 없앱니다. 지난 글의 스케일 모호성이 여기서 다시 나타나는 셈입니다.

③ 파라미터화. 회전을 오일러 각으로 두면 짐벌락에 걸립니다. 축-각(리 대수 $\mathfrak{so}(3)$)이나 쿼터니언을 쓰고, 갱신은 접공간에서 더한 뒤 다시 다양체로 사영합니다. 아주 먼 점은 역깊이로 두어야 수치가 안정됩니다.

④ 언제 도느냐. 전역 번들 조정은 비쌉니다. 실시간 SLAM은 최근 몇 프레임만 최적화하는 로컬 BA / 슬라이딩 윈도우를 돌리고, 루프 클로저처럼 큰 사건이 있을 때만 전역 최적화를 합니다. 더 가볍게는 점을 다 빼고 카메라 자세끼리의 상대 제약만 남긴 포즈 그래프 최적화를 씁니다.


8. 전체 그림

지금까지 세 편의 글에서 다룬 조각을 순서대로 놓으면 SfM 파이프라인이 완성됩니다.

  1. 특징점 검출·매칭
  2. 에피폴라 기하 — RANSAC + 8점/5점으로 $F$, $E$ 추정, 잘못된 매칭 제거
  3. $E$ 분해 → 두 시점 사이의 $(R, t)$
  4. 삼각측량 → 초기 3차원 점
  5. PnP로 새 카메라 추가 → 새 점 삼각측량 (증분 SfM)
  6. 번들 조정 — 전부 함께 다듬기
  7. 결과: 카메라 포즈 + 희소 점군 → COLMAP의 출력

그리고 7번이 첫 글에서 3DGS의 입력으로 그냥 주어졌던 바로 그것입니다. 한 바퀴가 닫혔습니다.

이 파이프라인을 조립 순서 관점에서 — 초기 쌍은 어떻게 고르고, 다음 이미지는 어떤 기준으로 넣고, 어디서 실패하는지 — 따로 정리한 글이 SfM 파이프라인입니다.


마치며

세 가지로 줄입니다.

  1. 삼각측량 — 광선 두 개의 교점. DLT로 선형해를 얻고 재투영 오차로 다듬는다. 정확도는 알고리즘보다 베이스라인이 좌우한다.
  2. 번들 조정 — 카메라와 점을 전부 미지수로 놓고 재투영 오차 총합을 최소화하는 비선형 최소제곱. 드리프트에 대한 정공법이다.
  3. Schur 보수 — 점끼리 직접 연결되지 않는다는 구조 덕분에 점을 싸게 소거할 수 있고, 그래서 30만 차원 문제가 풀린다.

다음 글에서는 이 최적화를 실시간 제약 아래에서 돌리는 이야기, 즉 SLAM의 프론트엔드와 백엔드가 어떻게 역할을 나누는지로 넘어가 보겠습니다.


참고문헌

  1. Hartley, R., & Sturm, P. (1997). Triangulation. Computer Vision and Image Understanding, 68(2), 146–157. ↩︎

  2. Triggs, B., McLauchlan, P., Hartley, R., & Fitzgibbon, A. (2000). Bundle Adjustment — A Modern Synthesis. Vision Algorithms: Theory and Practice, LNCS 1883, 298–372. ↩︎

이 기사는 저작권자의 CC BY-NC-ND 4.0 라이센스를 따릅니다.

License

CC BY-NC-ND 4.0

이 포스트는 CC BY-NC-ND 4.0 라이선스로 제공됩니다. 출처 표시는 필요하며, 상업적 이용과 변경은 허용되지 않습니다.