CV

두 카메라 스테레오 캘리브레이션 (ChArUco 기반)

하리우라 2026. 4. 2. 15:27
 

왜 캘리브레이션이 필요한가

카메라는 3D 세계를 2D 이미지로 투영하는 장치입니다. 이 과정에서 두 가지 문제가 생깁니다.

  1. 렌즈 왜곡: 실제 직선이 이미지에서 휘어 보임 (배럴/핀쿠션 왜곡)
  2. 투영 관계의 불확실성: 픽셀 좌표와 실제 3D 좌표 사이의 수치 관계를 모름

캘리브레이션은 이 두 가지를 수치로 정확히 구하는 과정입니다.

두 카메라를 함께 쓸 때는 추가로 **두 카메라 사이의 상대 위치·자세(외부 파라미터)**도 구해야 합니다. 이것이 있어야 두 이미지를 픽셀 단위로 정렬할 수 있습니다.


캘리브레이션으로 얻는 결과물

결과물의미
cameraMatrix (K) 초점거리(fx, fy), 주점(cx, cy) — 내부 파라미터
distCoeffs 렌즈 왜곡 계수 (k1, k2, p1, p2, ...)
R, T 카메라1 → 카메라2 회전·평행이동 — 스테레오 외부 파라미터
E, F Essential/Fundamental Matrix — 두 카메라 간 기하학적 제약

왜 ChArUco 보드인가

기존 체커보드 대신 ArUco 마커가 박힌 체커보드를 사용합니다.

  • 체커보드의 단점: 코너가 일부만 보이면 어느 코너인지 ID를 알 수 없음
  • ChArUco의 장점: 각 코너 근처에 고유 ID 마커가 있어서, 보드가 부분적으로 잘려도 어느 코너인지 식별 가능

실제 환경에서 보드를 다양한 각도로 기울이거나 이미지 가장자리에 걸칠 때 훨씬 안정적으로 동작합니다.


전체 파이프라인

1. ChArUco 보드 제작 및 정의
        ↓
2. 이미지 수집 (카메라1, 카메라2 동기화 쌍)
        ↓
3. 각 이미지에서 ChArUco 코너 검출
        ↓
4. 단일 카메라 내부 파라미터 캘리브레이션 (카메라1, 카메라2 각각 독립적으로)
        ↓
5. 스테레오 캘리브레이션 (외부 파라미터 R, T 산출)
        ↓
6. 이미지 정렬 (스테레오 렉티피케이션 또는 호모그래피)

Step 1. 보드 제작과 코드 정의

보드를 인쇄한 뒤 실제 정사각형 한 변의 길이를 자로 직접 측정합니다. 이 수치가 코드와 일치하지 않으면 스케일이 틀려집니다.

ARUCO_DICT = cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_6X6_250)

board = cv2.aruco.CharucoBoard_create(
    squaresX=5,
    squaresY=5,
    squareLength=0.12,   # 실제 측정값 (미터 단위)
    markerLength=0.09,   # ArUco 마커 크기 (squareLength보다 작아야 함)
    dictionary=ARUCO_DICT
)

단위는 미터든 센티미터든 무관하지만 프로젝트 전체에서 일관성이 있어야 합니다. T(평행이동 벡터)의 단위가 여기서 결정됩니다.


Step 2. 이미지 수집 시 주의사항

  • 장수: 최소 15~20쌍 이상 권장
  • 다양성: 다양한 각도, 거리, 화면 내 위치에서 촬영해야 왜곡 계수 추정이 안정적입니다. 한 자세로만 찍으면 특정 방향 왜곡 추정이 부정확해집니다
  • 동기화: 두 카메라의 쌍은 같은 시점에 찍혀야 합니다. 타임스탬프 차이가 크면 스테레오 외부 파라미터가 틀어집니다
  • 프레임 샘플링: 연속 촬영 영상에서 추출할 경우 인접 프레임은 거의 동일하므로 일정 간격(예: 10프레임마다 1장)으로 샘플링하는 것이 효율적입니다

Step 3. ChArUco 코너 검출

검출은 두 단계로 이루어집니다.

def detect_charuco(image, board, dictionary):
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if image.ndim == 3 else image

    # ① ArUco 마커 검출 (어느 코너인지 ID 파악)
    params = cv2.aruco.DetectorParameters_create()
    corners, ids, rejected = cv2.aruco.detectMarkers(gray, dictionary, parameters=params)

    if ids is None or len(ids) == 0:
        return False, None, None

    # ② ChArUco 코너 보간 (마커 사이 교차점을 서브픽셀 정밀도로 계산)
    retval, charuco_corners, charuco_ids = cv2.aruco.interpolateCornersCharuco(
        markerCorners=corners,
        markerIds=ids,
        image=gray,
        board=board
    )

    if charuco_corners is None or len(charuco_ids) == 0:
        return False, None, None

    return True, charuco_corners, charuco_ids

ArUco 마커로 "어느 코너인지"를 파악하고, 실제 정밀 좌표는 체스판 코너(교차점)에서 얻습니다.


Step 4. 단일 카메라 내부 파라미터 캘리브레이션

두 카메라를 독립적으로 각각 캘리브레이션합니다.

ret, cameraMatrix, distCoeffs, rvecs, tvecs = cv2.aruco.calibrateCameraCharuco(
    charucoCorners=all_corners,  # 각 이미지의 코너 좌표 리스트
    charucoIds=all_ids,          # 각 이미지의 코너 ID 리스트
    board=board,
    imageSize=image_size,
    cameraMatrix=None,
    distCoeffs=None,
)
print("RMS reprojection error:", ret)

ret은 RMS 재투영 오차(reprojection error) 입니다. "3D 점을 추정된 파라미터로 다시 2D에 투영했을 때 실제 검출 위치와 얼마나 차이나는가"를 픽셀 단위로 나타냅니다.

RMS 범위상태
0.5px 이하 양호
0.5 ~ 1.0px 보통 (데이터 다양성 부족 가능성)
1.0px 이상 데이터 품질 재검토 필요

Step 5. 스테레오 캘리브레이션

두 카메라가 같은 보드의 같은 코너를 동시에 본 이미지 쌍을 사용합니다. 각 쌍에서 공통으로 보이는 코너 ID를 기준으로 2D 좌표 대응을 만듭니다.

# 공통 코너 ID 추출
common_ids, idx1, idx2 = np.intersect1d(ids_cam1, ids_cam2, return_indices=True)

# 공통 코너의 3D 보드 좌표 및 각 카메라의 2D 좌표
obj_pts     = board.chessboardCorners[common_ids]  # (N, 3)
img_pts_cam1 = corners_cam1[idx1]
img_pts_cam2 = corners_cam2[idx2]

수집한 모든 쌍을 묶어 스테레오 캘리브레이션을 실행합니다.

retval, _, _, _, _, R, T, E, F = cv2.stereoCalibrate(
    objectPoints=objp_list,
    imagePoints1=pts_cam1_list,
    imagePoints2=pts_cam2_list,
    cameraMatrix1=cam1_mtx,
    distCoeffs1=cam1_dist,
    cameraMatrix2=cam2_mtx,
    distCoeffs2=cam2_dist,
    imageSize=image_size,
    flags=cv2.CALIB_FIX_INTRINSIC,
    criteria=(cv2.TERM_CRITERIA_MAX_ITER + cv2.TERM_CRITERIA_EPS, 100, 1e-5)
)
print("Stereo RMS:", retval)

CALIB_FIX_INTRINSIC이 핵심입니다. Step 4에서 구한 내부 파라미터를 고정하고 R과 T만 최적화합니다. 이것이 표준적인 2단계 접근법입니다. 모든 파라미터를 동시에 최적화하면 자유도가 너무 높아져 오히려 불안정해집니다.


Step 6. 결과 활용 — 두 가지 방법

방법 A: 스테레오 렉티피케이션

두 카메라 이미지를 에피폴라 라인이 수평이 되도록 변환합니다. 스테레오 깊이 추정이나 정밀 정렬에 적합합니다.

R1, R2, P1, P2, Q, roi1, roi2 = cv2.stereoRectify(
    cam1_mtx, cam1_dist,
    cam2_mtx, cam2_dist,
    image_size, R, T,
    flags=cv2.CALIB_ZERO_DISPARITY
)

map1, map2 = cv2.initUndistortRectifyMap(
    cam1_mtx, cam1_dist, R1, P1, image_size, cv2.CV_32FC1
)
cam1_rectified = cv2.remap(img_cam1, map1, map2, cv2.INTER_LINEAR)

렉티피케이션 맵(map1, map2)은 한 번만 계산해 저장해두면, 이후 매 프레임마다 cv2.remap만 호출하면 됩니다.

방법 B: 호모그래피로 카메라2 → 카메라1 정렬

R, T, 두 카메라의 내부 파라미터를 알고 있으면 씬이 특정 깊이의 평면에 있다는 가정 하에 투영 변환 행렬(호모그래피 H)을 수식으로 직접 유도할 수 있습니다.

유도된 H를 사용하면 렉티피케이션 없이도 한 카메라의 이미지를 다른 카메라에 맞게 변환할 수 있습니다.

warped = cv2.warpPerspective(img_cam2, H, image_size)

주의: 이 방법은 씬이 단일 평면이라는 가정에 의존합니다. 깊이 변화가 큰 환경에서는 오정렬이 생기므로, 그런 경우에는 방법 A(렉티피케이션)를 권장합니다.


결과 저장 및 재사용

# 저장
np.savez("calib_cam1.npz", cameraMatrix=cam1_mtx, distCoeffs=cam1_dist)
np.savez("calib_cam2.npz", cameraMatrix=cam2_mtx, distCoeffs=cam2_dist)
np.savez("stereo_calib.npz", R=R, T=T, E=E, F=F)

# 불러오기
data = np.load("stereo_calib.npz")
R, T = data["R"], data["T"]

렉티피케이션 맵도 같은 방식으로 저장해두면 실시간 처리 시 재사용할 수 있습니다.


품질 점검 체크리스트

수집 단계

  •  보드의 squareLength를 실제로 측정했는가
  •  두 카메라 쌍의 타임스탬프 차이가 충분히 작은가
  •  다양한 각도/거리/위치에서 15장 이상 촬영했는가

캘리브레이션 단계

  •  단일 카메라 RMS < 0.5px
  •  스테레오 RMS < 1.0px
  •  언디스토션 이미지에서 직선이 실제로 직선으로 보이는가

정렬 결과 확인

  •  렉티피케이션 후 에피폴라 라인이 수평으로 정렬되는가
  •  두 카메라 이미지의 특징점 위치가 일치하는가

마치며

캘리브레이션에서 가장 중요한 것은 코드가 아니라 좋은 데이터를 수집하는 것입니다. 아무리 정확한 알고리즘도 한 자세로만 찍은 이미지로는 왜곡 계수를 제대로 추정하지 못합니다. 다양한 자세로 충분히 수집하고, RMS 오차로 품질을 확인하는 습관을 들이는 것이 핵심입니다.